Compare commits

..
93 Commits
Author SHA1 Message Date
Joel Male d1169f502f Merge pull request #11 from wearepixel/feature/v3
feat: v3 - storage drivers, first-class objects, testing utilities, Livewire/Inertia support, Artisan generators
2026-06-13 15:57:26 +10:00
Joel Male bfdea648ae chore: merge master, resolve conflicts keeping v3 2026-06-13 14:33:14 +10:00
Joel Male 01189993f2 feat: add applies_to support for quantity-limited item conditions 2026-06-13 14:27:42 +10:00
dependabot[bot] 3375518354 chore(deps): bump shivammathur/setup-php in the actions group
Bumps the actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.1 to 2.37.2
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc...f3e473d116dcccaddc5834248c87452386958240)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 14:21:41 +10:00
dependabot[bot] c493c73497 chore(deps): bump actions/checkout in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 14:21:41 +10:00
Joel Male 7427ce953a chore: bump version to 2.1.4 2026-06-13 14:19:39 +10:00
Joel Male 6ba50a1c48 fix: prevent save() on array after clear() with database driver 2026-06-13 14:18:55 +10:00
Joel Male c50fc70688 fix: restore @mixin and @method PHPDoc on CartFacade for IDE and PHPStan support 2026-06-13 14:17:21 +10:00
Joel Male c9ffd8b8a4 chore: update to v3.0.0 2026-06-13 14:16:28 +10:00
Joel Male f3c4ddae6e docs: add v3 recommended patterns to upgrade guide and link from README 2026-06-13 14:11:20 +10:00
Joel Male 3f1f5fb4d2 fix: add missing return type to getAssociatedModel 2026-06-13 14:09:29 +10:00
github-actions[bot] fce7125c5f Merge pull request #14 from wearepixel/dependabot/github_actions/actions-5dacb66663
chore(deps): bump shivammathur/setup-php from 2.37.1 to 2.37.2 in the actions group
2026-06-08 21:45:38 +00:00
dependabot[bot] 834e211443 chore(deps): bump shivammathur/setup-php in the actions group
Bumps the actions group with 1 update: [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `shivammathur/setup-php` from 2.37.1 to 2.37.2
- [Release notes](https://github.com/shivammathur/setup-php/releases)
- [Commits](https://github.com/shivammathur/setup-php/compare/7c071dfe9dc99bdf297fa79cb49ea005b9fcadbc...f3e473d116dcccaddc5834248c87452386958240)

---
updated-dependencies:
- dependency-name: shivammathur/setup-php
  dependency-version: 2.37.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 21:45:26 +00:00
Joel Male 2ced1038af feat: add PHPStan level 5 with zero errors
- Install phpstan/phpstan, add phpstan.neon at level 5 targeting PHP 8.3
- Add phpstan-bootstrap.php stubs for Laravel helper functions (app,
  config, config_path, app_path, session) unavailable in standalone packages
- Fix CartCondition::setOrder() - removed incorrect @return int (method is void)
- Fix CartCondition::getCalculatedValue() - removed incorrect @return mixed PHPDoc
- Fix Cart::condition() - remove redundant instanceof check the type system already guarantees
- Fix Cart::fireEvent() - remove array_values() on already-list literal array
- Fix ItemCollection - add @property annotations for magic __get properties
- Fix ItemCollection::getAssociatedModel() - correct @return bool to @return mixed
- Fix ItemCollection::getConditions() - remove dead is_array && !instanceof check
- Fix Validator - add @method stub so PHPStan resolves make() through __callStatic
- Add ignoreErrors for trait.unused (HasCart designed for external Livewire use)
- Add ignoreErrors for round() mode mismatch (PHPStan 2.x PHP 8.4 stubs bug)
- Add phpstan step to CI (PHP 8.3 only, matches phpVersion config)
2026-06-06 16:02:16 +10:00
github-actions[bot] 9b198edb7d Merge pull request #13 from wearepixel/dependabot/github_actions/actions-6a98abd9ac
chore(deps): bump actions/checkout from 6.0.2 to 6.0.3 in the actions group
2026-06-03 10:48:17 +00:00
dependabot[bot] 49959faace chore(deps): bump actions/checkout in the actions group
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6.0.2 to 6.0.3
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...df4cb1c069e1874edd31b4311f1884172cec0e10)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-03 10:48:04 +00:00
Joel Male e61ffae5fe chore: bump version to 2.1.3 2026-05-29 14:27:02 +10:00
Joel Male 10ab30f4e8 fix: correct add() @method signature to accept array input style 2026-05-29 14:14:34 +10:00
Joel Male 34626eef5e chore: bump version to 2.1.2 2026-05-29 14:12:49 +10:00
Joel Male 6020b655e2 Merge pull request #12 from wearepixel/fix/phpstan-facade-types
fix: add PHPStan/Larastan support to CartFacade
2026-05-29 14:11:47 +10:00
Joel Male 2c56062f97 fix: add @mixin and @method PHPDoc to CartFacade for PHPStan/Larastan support 2026-05-29 14:09:15 +10:00
Joel Male 88a118d81b feat: make SessionDriver constructor args optional with app() defaults
new SessionDriver() now works out of the box in a Laravel app, resolving
the session and session ID from the container. Explicit args still work
as before. Also ignores .claude/ directory from version control.
2026-05-28 09:25:41 +10:00
Joel Male 4f87bd645c docs: update README for v3 features
- Fix vendor:publish tag to cart-config
- Update TOC with Storage Drivers, Artisan Generators, First-class Objects, Testing, and Livewire / Inertia sections
- Replace Storage Options section with expanded Storage Drivers section covering session, database, Redis, multi-driver, and custom drivers
- Add Artisan Generators section documenting cart:install, cart:make:coupon, cart:make:tax, cart:make:shipping, cart:make:driver, and cart:debug
- Add First-class Objects section documenting Coupon, TaxRule, and ShippingRate base classes
- Add Testing section documenting Cart::fake() and assertion methods
- Add Livewire / Inertia section documenting HasCart trait and ShareCartWithInertia
2026-05-27 21:53:37 +10:00
Joel Male bab304c620 docs: add UPGRADE.md and CHANGELOG.md 2026-05-27 21:50:54 +10:00
Joel Male fd77347542 fix: validate class name input and check file write in Make commands
Add path traversal protection via PHP class name regex validation to all
four Make* commands, check file_put_contents() return value for write
failures, and remove the unnecessary \$defer = false property from
CartServiceProvider.
2026-05-27 21:49:08 +10:00
Joel Male ce51042edf feat: register Artisan commands in CartServiceProvider 2026-05-27 21:45:16 +10:00
Joel Male c2b2563cf1 feat: add cart:debug command 2026-05-27 21:44:47 +10:00
Joel Male 15d4397968 feat: add cart:make:driver command 2026-05-27 21:43:59 +10:00
Joel Male 76fba35643 feat: add cart:make:shipping command 2026-05-27 21:43:34 +10:00
Joel Male b8140166c9 feat: add cart:make:tax command 2026-05-27 21:43:14 +10:00
Joel Male fc16e6c877 feat: add cart:make:coupon command 2026-05-27 21:42:51 +10:00
Joel Male 731aaa070d feat: add cart:install command 2026-05-27 21:42:28 +10:00
Joel Male 0efd4378a8 feat: add HasCart trait and ShareCartWithInertia 2026-05-27 21:38:44 +10:00
Joel Male a485bfb6f0 refactor: use assertEqualsWithDelta for float comparisons, add @internal to fake()
Replace Assert::assertEquals with Assert::assertEqualsWithDelta (epsilon 0.0001)
in assertSubTotal, assertTotal, and assertTotalQuantity to avoid IEEE 754
floating-point equality pitfalls. Add @internal docblock to fake() to signal
it is test-only. Add regression test for consecutive withItems calls.
2026-05-27 21:35:49 +10:00
Joel Male 8d04d18b48 test: add withCoupon test and fix withItems ID collision 2026-05-27 21:33:35 +10:00
Joel Male 61863e0bd5 feat: add Cart::fake(), assertion methods, and CartFactory
Adds Cart::fake() which swaps the active driver to NullDriver and returns
a CartFactory fluent test builder. Adds assertion methods (assertContains,
assertCount, assertTotalQuantity, assertSubTotal, assertTotal,
assertConditionApplied, assertEmpty, assertNotEmpty) to Cart for use in
tests. CartFactory provides withItems(), withCondition(), and withCoupon()
as chainable helpers for seeding cart state.
2026-05-27 21:31:54 +10:00
Joel Male c3a734bd7f feat: add Cart::coupon(), Cart::tax(), Cart::shipping()
Proxy methods that accept first-class Coupon, TaxRule, and ShippingRate
objects and delegate to Cart::condition() via toCondition().
2026-05-27 21:27:23 +10:00
Joel Male 2513e07436 feat: add ShippingRate base class 2026-05-27 21:24:44 +10:00
Joel Male b5edbb8ec0 feat: add TaxRule base class 2026-05-27 21:24:09 +10:00
Joel Male 867daaf328 feat: add Coupon base class 2026-05-27 21:23:39 +10:00
Joel Male e41739c436 feat: add CartConditionable interface and first-class exceptions 2026-05-27 21:23:07 +10:00
Joel Male 4877911cdd fix: serialize conditions to arrays before storage; type \$events
- serializeConditions() now maps CartCondition objects to arrays via
  toArray() before passing to the driver, preventing json_encode()
  producing {} for object values in the DatabaseDriver
- Add Dispatcher type hint to \$events property and constructor parameter
- Add database round-trip test for cart-level conditions
- Fix CartConditionsTest to retrieve condition objects from the cart
  rather than relying on object-reference side-effects that no longer
  hold after proper serialization
2026-05-27 21:21:44 +10:00
Joel Male f3e5e958b7 fix: resolve Cart.php regressions after driver refactor
- Cast \$value to string in updateQuantityRelative() before passing to preg_match() and str_replace() to fix TypeError when integer quantities are passed
- Reconstruct ItemAttributeCollection and CartCondition objects from serialized arrays in getContent() so attribute property access (->size) and condition methods (.getName()) work correctly after deserialization
- Store CartCondition objects directly (not serialized to arrays) in saveConditions() to preserve object identity for parsedRawValue tracking via getCalculatedValue()
- Remove ->values() re-indexing from serializeItems() conditions serialization to preserve original condition array indices after partial removal
2026-05-27 21:16:07 +10:00
Joel Male adf4bca74f chore: remove CartSession (replaced by driver system) 2026-05-27 21:09:50 +10:00
Joel Male 0c27ac4d9a test: update existing tests to use CartDriver 2026-05-27 21:09:40 +10:00
Joel Male 9abf6a7a1b feat: update CartServiceProvider to use CartManager 2026-05-27 21:08:10 +10:00
Joel Male e0103aadaa refactor: update Cart to use CartDriver interface
Replace the v2 Cart implementation (which used CartSession directly) with
the v3 design that accepts a CartDriver contract via constructor injection.
Removes validateConfig(), replaces $this->session with $this->driver, adds
getDriver()/extend() helpers, serializes items/conditions through the driver,
and drops the unused setDecimals() method.
2026-05-27 21:06:22 +10:00
Joel Male 90ea8c44e4 fix: correct Redis TTL units in config (seconds not minutes) 2026-05-27 21:03:18 +10:00
Joel Male 3b8fa4c838 feat: update cart config with v3 driver options 2026-05-27 21:02:32 +10:00
Joel Male 2f2d268e0f feat: add CartManager 2026-05-27 21:02:23 +10:00
Joel Male 71213d51f5 fix: null-safe json_decode in RedisDriver; add getSessionModel test to MultiDriverTest 2026-05-27 21:01:34 +10:00
Joel Male 83aa9a1431 feat: add MultiDriver 2026-05-27 20:59:45 +10:00
Joel Male 1705c23205 feat: add RedisDriver 2026-05-27 20:59:15 +10:00
Joel Male 9fcd50f81f test: add setSessionKey test to DatabaseDriverTest 2026-05-27 20:58:24 +10:00
Joel Male 4702cf98ab feat: add DatabaseDriver
Implements CartDriver for Eloquent-backed persistence using firstOrNew
pattern. Includes 9 unit tests covering CRUD, flush, clearItems, and
model retrieval.
2026-05-27 20:55:01 +10:00
Joel Male 8927ef08a0 fix: guard null writes in SessionDriver::setSessionKey 2026-05-27 20:53:19 +10:00
Joel Male c9f48d7e88 feat: add SessionDriver 2026-05-27 20:50:47 +10:00
Joel Male 11e23a946e test: fix strict_types and test name in NullDriverTest 2026-05-27 20:49:36 +10:00
Joel Male b3c4c53e63 feat: add NullDriver 2026-05-27 20:47:14 +10:00
Joel Male c9f3d76366 feat: add CartDriver interface 2026-05-27 20:46:48 +10:00
Joel Male b2a4864675 chore: ignore docs/superpowers/ 2026-05-27 20:22:32 +10:00
Joel Male aaa3360d1f chore: add test bootstrap, update phpunit config, and add Laravel 13 support to README
- Add tests/bootstrap.php to suppress PHP 8.4 vendor deprecation notices (Mockery implicit-nullable)
- Update phpunit.xml.dist to reference bootstrap, use PHPUnit 11.5 schema, and ignore indirect deprecations
- Note Laravel 13 support in README
2026-05-27 19:50:09 +10:00
Joel Male 05729b7865 chore: bump version to 2.1.1 2026-05-27 16:42:52 +10:00
Joel Male 7e6ee749bf Merge pull request #10 from wearepixel/dependabot/composer/pestphp/pest-4.7.0
chore(deps-dev): bump pestphp/pest from 3.x-dev to 4.7.0
2026-05-27 16:42:24 +10:00
Joel Male 17532f1b05 chore(deps-dev): bump pestphp/pest from 3.x-dev to 4.7.0
Resolves merge conflict with laravel/pint update - regenerated lock file
to include both pint 1.29.1 and pest 4.7.0 with all dependencies.
2026-05-27 16:42:16 +10:00
Joel Male d058ad5521 Merge pull request #9 from wearepixel/dependabot/composer/php-minor-patch-d003f11a51
chore(deps-dev): bump laravel/pint from 1.21.0 to 1.29.1 in the php-minor-patch group
2026-05-27 16:41:05 +10:00
Joel Male 3f5e9374c3 Merge pull request #8 from wearepixel/dependabot/github_actions/actions-c77b365b7e
chore(deps): bump the actions group across 1 directory with 3 updates
2026-05-27 16:41:02 +10:00
Joel Male fcdd356718 fix: update CI runner from deprecated ubuntu-20.04 to ubuntu-latest 2026-05-27 16:40:41 +10:00
dependabot[bot] 3500fa43a9 chore(deps): bump the actions group across 1 directory with 3 updates
Bumps the actions group with 3 updates in the / directory: [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata), [actions/checkout](https://github.com/actions/checkout) and [nick-fields/retry](https://github.com/nick-fields/retry).


Updates `dependabot/fetch-metadata` from 2.2.0 to 3.1.0
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/dbb049abf0d677abbd7f7eee0375145b417fdd34...25dd0e34f4fe68f24cc83900b1fe3fe149efef98)

Updates `actions/checkout` from 4.3.1 to 6.0.2
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/34e114876b0b11c390a56381ad16ebd13914f8d5...de0fac2e4500dabe0009e67214ff5f5447ce83dd)

Updates `nick-fields/retry` from 3.0.2 to 4.0.0
- [Release notes](https://github.com/nick-fields/retry/releases)
- [Commits](https://github.com/nick-fields/retry/compare/ce71cc2ab81d554ebbe88c79ab5975992d79ba08...ad984534de44a9489a53aefd81eb77f87c70dc60)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: dependabot/fetch-metadata
  dependency-version: 3.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: nick-fields/retry
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 06:26:30 +00:00
Joel Male 28a72581fc chore: pin GitHub Actions to full commit SHAs
chore: pin GitHub Actions to full commit SHAs
2026-05-27 16:24:23 +10:00
dependabot[bot] ddcf38dab0 chore(deps-dev): bump pestphp/pest from 3.x-dev to 4.7.0
Bumps [pestphp/pest](https://github.com/pestphp/pest) from 3.x-dev to 4.7.0.
- [Release notes](https://github.com/pestphp/pest/releases)
- [Changelog](https://github.com/pestphp/pest/blob/4.x/RELEASE.md)
- [Commits](https://github.com/pestphp/pest/commits/v4.7.0)

---
updated-dependencies:
- dependency-name: pestphp/pest
  dependency-version: 4.7.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 06:19:38 +00:00
dependabot[bot] e7bf1ba3a3 chore(deps-dev): bump laravel/pint in the php-minor-patch group
Bumps the php-minor-patch group with 1 update: [laravel/pint](https://github.com/laravel/pint).


Updates `laravel/pint` from 1.21.0 to 1.29.1
- [Release notes](https://github.com/laravel/pint/releases)
- [Changelog](https://github.com/laravel/pint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/laravel/pint/compare/v1.21.0...v1.29.1)

---
updated-dependencies:
- dependency-name: laravel/pint
  dependency-version: 1.29.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: php-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-27 06:19:22 +00:00
pixel-workflows[bot] ebf5a41e01 chore: update dependabot configuration 2026-05-27 06:18:28 +00:00
Joel Male e0d566cebd chore: pin GitHub Actions to full commit SHAs
Pins all GitHub Actions uses: references to full commit SHAs
to prevent supply chain attacks via mutable action tags.
Original tag references are kept as inline comments.
2026-05-27 14:46:49 +10:00
Joel Male 863ef52e8d Revise security policy and reporting guidelines
Removed supported versions section and updated reporting instructions.
2026-05-27 14:46:49 +10:00
Joel Male 6638f812c8 chore: bump version to 2.1.0 2026-03-24 14:06:08 +10:00
Joel Male 8f442f51e0 chore: migrate to wearepixel/laravel-cart 2026-03-24 14:04:48 +10:00
Joel Male 6dfd0ce761 chore: bump version to 2.0.0 2026-03-23 16:30:38 +10:00
Joel Male a3161888aa chore: bump to v2.0.0 with PHP 8.3+ support and invalid item cleanup 2026-03-23 16:28:53 +10:00
Joel Male 4c02310bf7 feat: rough session re-implementation 2025-04-14 16:53:35 +10:00
Joel Male e810eb4ef4 Merge pull request #2 from joelwmale/feature/database-support
Database Support
2025-02-25 16:55:44 +10:00
Joel Male 4cf078277d feat: finish tests & docs 2025-02-25 16:54:03 +10:00
Joel Male 7adf5ecd1f feat: add tests and fix item level conditions for database storage 2025-02-25 16:31:42 +10:00
Joel Male 158cf38f14 feat: update composer deps & version 2025-02-25 16:08:27 +10:00
Joel Male 0f07b9ae8a feat: database support 2025-02-25 16:03:17 +10:00
Joel Male ec99aa0563 feat: clean up and starting documentation 2025-02-25 14:53:49 +10:00
Joel Male 8d6e1bcc26 wip: readme updates 2025-02-25 13:49:09 +10:00
Joel Male b92dce9ca4 wip: initial working database support 2025-02-25 11:47:10 +10:00
Joel Male 2196f58f01 Merge pull request #1 from joelwmale/feature/laravel-12.x-support
Laravel 12.x Support
2025-02-25 08:57:01 +10:00
Joel Male ed4d112665 Laravel 12.x Support 2025-02-25 08:54:04 +10:00
Joel Male f60bf92157 1.0.12 2024-11-28 13:11:23 +10:00
Joel Male a231ed8c43 1.0.12 2024-11-28 13:06:30 +10:00
Joel Male 599051631c wip: rename package to joelwmale 2024-11-28 13:06:24 +10:00
80 changed files with 8453 additions and 2282 deletions
+21 -5
View File
@@ -1,17 +1,33 @@
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:
- "deps"
- "dependencies"
- 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:
- "deps"
- "dependencies"
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- name: Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2.2.0
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
+10 -7
View File
@@ -9,19 +9,19 @@ on:
jobs:
tests:
name: Tests
runs-on: ubuntu-20.04
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
php: ['8.1', '8.2', '8.3']
php: ["8.3", "8.4", "8.5"]
stability: [prefer-stable]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup PHP
uses: shivammathur/setup-php@v2
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached
@@ -29,12 +29,15 @@ jobs:
coverage: none
- name: Install dependencies
uses: nick-fields/retry@v3
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
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
run: vendor/bin/pest
- name: Static analysis
run: vendor/bin/phpstan analyse --no-progress
if: matrix.php == '8.3'
+3 -1
View File
@@ -3,4 +3,6 @@
.idea
.phpunit.result.cache
READMETODO.md
build
build
docs/superpowers/
.claude/
+61
View File
@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [3.0.0] - TBD
### Added
- **Storage driver system** (`CartDriver` interface, `CartManager`, `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver`)
- **`Cart::fake()`** - swaps the active driver for a `NullDriver` and returns a `CartFactory` for fluent test state seeding
- **Assertion methods** on `Cart`: `assertContains()`, `assertCount()`, `assertTotalQuantity()`, `assertSubTotal()`, `assertTotal()`, `assertConditionApplied()`, `assertEmpty()`, `assertNotEmpty()`
- **`CartFactory`** - fluent builder for seeding test cart state (`withItems()`, `withCondition()`, `withCoupon()`)
- **`Coupon` abstract class** - extend to create first-class coupon objects that convert to `CartCondition`
- **`TaxRule` abstract class** - extend to create first-class tax rules that convert to `CartCondition`
- **`ShippingRate` abstract class** - extend to create first-class shipping rates that convert to `CartCondition`
- **`Cart::coupon()`**, **`Cart::tax()`**, **`Cart::shipping()`** - accept first-class objects or raw `CartCondition`
- **`HasCart` trait** - Livewire-aware trait with reactive `$cartItems`, `$cartTotal`, `$cartSubTotal`, `$cartCount` and proxy methods
- **`ShareCartWithInertia`** - static class for sharing cart state via `HandleInertiaRequests::share()`
- **`cart:install`** Artisan command - publishes config and scaffolds `app/Cart/` directory structure
- **`cart:make:coupon`** Artisan command - generates a `Coupon` subclass stub
- **`cart:make:tax`** Artisan command - generates a `TaxRule` subclass stub
- **`cart:make:shipping`** Artisan command - generates a `ShippingRate` subclass stub
- **`cart:make:driver`** Artisan command - generates a `CartDriver` implementation stub
- **`cart:debug`** Artisan command - dumps current cart state (non-production only)
- Laravel 13 support
### Changed
- `Cart` constructor now accepts a `CartDriver` instance instead of raw session/database storage
- Config publish tag renamed from `config` to `cart-config`
### Removed
- `Wearepixel\Cart\CartSession` - replaced by the driver system
## [2.1.1] - 2026-05-27
### Fixed
- PHP 8.4 deprecation warnings from Mockery implicit-nullable parameters in test bootstrap
- Upgraded CI runners from deprecated `ubuntu-20.04` to `ubuntu-latest`
### Changed
- `pestphp/pest` upgraded from `^3.0` to `^4.7`
## [2.1.0] - 2024-01-01
### Added
- Laravel 12 support
## [2.0.0] - 2023-01-01
### Added
- Initial v2 release
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) Pixel Digital Pty Ltd <hello@wearepixel.com.au>
Copyright (c) Joel Male <joel@joelmale.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+524 -85
View File
@@ -1,49 +1,88 @@
<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: 9, 10, 11
Supported Laravel Versions: 10, 11, 12, and 13.
## Quick Demo
For Laravel 9.0 and below, please use version [1.0](https://github.com/wearepixel/laravel-cart/releases/tag/1.0.12)
```php
// Add an item to the cart
\Cart::add(1, 'Product 1', 19.99, 2, ['size' => 'large']);
> **Upgrading from v2?** See the [Upgrade Guide](UPGRADE.md) for breaking changes and recommended v3 patterns.
// Get the cart contents
$cartContents = \Cart::getContent();
## Table of Contents
// Update item quantity by its id
\Cart::update(1, ['quantity' => 3]);
- [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)
// Remove an item from the cart by its id
\Cart::remove(1);
## 🚀 Getting Started
// Get cart total
\$total = Cart::getTotal();
// Clear the entire cart
\Cart::clear();
```
## Installing
### 🔥 Installing
Install the package through [Composer](http://getcomposer.org/).
`composer require "wearepixel/laravel-cart"`
`composer require wearepixel/laravel-cart`
## Configuration
## 🧑‍🍳 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
);
// get the entire cart
$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
);
// remove the item by its id
\Cart::remove(1);
// get the total of the cart
$total = Cart::getTotal();
// clear it all when you've finished (like when you've stored the order)
\Cart::clear();
```
## 📚 Documentation
### ⚙️ Configuration
You can publish the configuration file to customize various options.
```php
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="config"
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="cart-config"
```
### Formatting Numbers
#### Formatting Numbers
The package by default does not use round to format numbers, and instead returns the number using floatval().
@@ -56,7 +95,7 @@ ### Formatting Numbers
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
### Decimals
#### Decimals
You can customize the number of decimals in the configuration file.
@@ -66,7 +105,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.
@@ -76,40 +115,22 @@ ### Round Mode
'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
```
## Usage
### Basic Usage
- [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)
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.
## Usage
This also serves as a cart unique identifier which you can use to bind a cart to a specific user if you want to.
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.
Make sure to call `\Cart::setSessionKey($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::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']);
\Cart::setSessionKey(User::first()->id);
```
### Adding to the cart: **Cart::add()**
#### Adding to the cart: **Cart::add()**
There are a few ways to add items to the cart.
@@ -154,8 +175,8 @@ ### Adding to the cart: **Cart::add()**
[], // attributes
[
[
'name' => '10% Off',
'type' => 'discount',
'name' => '10% Off',
'type' => 'discount',
'value' => '-10%'
]
] // conditions
@@ -180,12 +201,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
]
@@ -201,7 +222,7 @@ ### Updating an item on a cart: **Cart::update()**
// reducing it...
Cart::update(
456,
456,
[
'quantity' => -1, // so if from 4 to 3
]
@@ -219,14 +240,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
@@ -236,7 +257,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
@@ -256,13 +277,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();
@@ -274,19 +295,29 @@ ### 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()**
#### Clearing the Cart: **Cart::clear()**
This clears all items and conditions from the cart.
```php
Cart::clear();
```
## Conditions
#### 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 are very useful for adding things like discounts, taxes, shipping, etc.
@@ -298,9 +329,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
@@ -351,7 +382,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:
@@ -370,7 +401,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.
@@ -389,7 +420,7 @@ #### Getting conditions on the cart as an array: **Cart::getConditions(array: tr
}
```
#### Getting conditions by name: **Cart::getCondition($conditionName)**
##### Getting conditions by name: **Cart::getCondition($conditionName)**
```php
$condition = Cart::getCondition('GST');
@@ -403,7 +434,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.
@@ -439,14 +470,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();
@@ -454,7 +485,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.
@@ -487,7 +518,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.
@@ -506,7 +537,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.
@@ -525,7 +556,7 @@ ### Adding conditions that only activate up to a maximum value
Cart::condition($shipping)
```
### Condition on items
#### Conditions on items
Item conditions are useful if you have discounts to be applied specifically on an item and not on the whole cart value.
@@ -576,6 +607,42 @@ ### Condition 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.
@@ -586,7 +653,7 @@ ### Condition on 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.
@@ -600,7 +667,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.
@@ -614,25 +681,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.
@@ -640,13 +707,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.
@@ -668,8 +735,380 @@ ## Cart Items
$item->getPriceSumWithConditions();
```
## Credits
### Storage Drivers
Huge credits to the original maintainer of the package, located at [darryldecode/laravelshoppingcart](https://github.com/darryldecode/laravelshoppingcart)
The cart supports multiple storage backends via a driver system.
We have forked this package to update it to Laravel 11 and to add some features and support for newer PHP versions.
#### 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.
+11
View File
@@ -0,0 +1,11 @@
# 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
@@ -0,0 +1,165 @@
# Upgrade Guide
## v2.x to v3.0
### Breaking changes
#### Constructor signature
`Cart` no longer accepts a raw session/database storage object as its first constructor argument. It now requires a `CartDriver` instance. If you were resolving `Cart` directly (not via the service container), update your instantiation:
**Before:**
```php
new Cart($sessionStorage, $events, 'cart', $sessionKey, $config);
```
**After:**
```php
use Wearepixel\Cart\Drivers\SessionDriver;
new Cart(new SessionDriver($session, $sessionKey), $events, 'cart', $config);
```
Applications that use the service container (the normal case) are unaffected - `CartServiceProvider` handles this automatically.
#### Publish tag renamed
The config publish tag changed from `config` to `cart-config` to avoid conflicts with other packages:
**Before:**
```bash
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=config
```
**After:**
```bash
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag=cart-config
# or simply:
php artisan cart:install
```
#### CartSession removed
`Wearepixel\Cart\CartSession` has been removed. It is replaced by the driver system. If you extended or referenced `CartSession` directly, switch to the appropriate driver (`SessionDriver`, `DatabaseDriver`, etc.).
### Non-breaking additions
The following are additive and require no changes to existing code:
- **Storage drivers:** `SessionDriver`, `DatabaseDriver`, `RedisDriver`, `NullDriver`, `MultiDriver` - configure via `config/cart.php`
- **CartManager:** resolves drivers by name, accessible via `app('cart.manager')`
- **First-class objects:** `Coupon`, `TaxRule`, `ShippingRate` abstract base classes - optional, existing raw `CartCondition` usage continues to work
- **Cart::fake():** returns a `CartFactory` backed by an in-memory `NullDriver` for testing
- **Assertion methods:** `Cart::assertContains()`, `assertCount()`, `assertTotal()`, etc.
- **HasCart trait:** Livewire reactive cart state
- **ShareCartWithInertia:** static class for Inertia middleware integration
- **Artisan generators:** `cart:install`, `cart:make:coupon`, `cart:make:tax`, `cart:make:shipping`, `cart:make:driver`, `cart:debug`
---
### Recommended v3 patterns
v3 is fully backwards compatible - your existing code will continue to work unchanged. The following patterns are recommended for new code and worth migrating to when you touch existing cart logic.
#### 1. Publish the updated config
Run the installer to get the new `driver` key and storage configuration in your `config/cart.php`:
```bash
php artisan cart:install
```
The default driver is `session`, so behaviour is identical to v2 unless you change it.
#### 2. Replace raw CartCondition arrays with first-class objects
Generate typed classes with Artisan instead of constructing arrays inline:
```bash
php artisan cart:make:coupon TenPercentOff
php artisan cart:make:tax GstTaxRule
php artisan cart:make:shipping FlatRateShipping
```
**Before:**
```php
$condition = new CartCondition([
'name' => 'SAVE10',
'type' => 'coupon',
'target' => 'subtotal',
'value' => '-10%',
]);
Cart::condition($condition);
```
**After:**
```php
// app/Cart/Coupons/TenPercentOff.php
class TenPercentOff extends Coupon
{
protected string $code = 'SAVE10';
protected string $value = '-10%';
protected string $target = 'subtotal';
public function isValid(): bool
{
return true;
}
}
Cart::coupon(new TenPercentOff);
Cart::tax(new GstTaxRule);
Cart::shipping(new FlatRateShipping);
```
The same pattern applies to `TaxRule` (implement `isApplicable()`) and `ShippingRate` (implement `isApplicable()`).
#### 3. Use Cart::fake() in tests instead of mocking
**Before:**
```php
// Mocking the session or binding a fake driver manually
$this->mock(CartDriver::class, function ($mock) {
$mock->shouldReceive(...);
});
```
**After:**
```php
$factory = app('cart')->fake();
$factory->withItems(3)->withCondition(new GstTaxRule);
$cart = app('cart');
$cart->assertCount(3);
$cart->assertConditionApplied('GST');
$cart->assertTotal(33.00);
```
`Cart::fake()` swaps the driver to an in-memory `NullDriver` for the duration of the test - no session, database, or Redis needed.
#### 4. Switch to a persistent driver (optional)
If you want cart state to survive beyond a session, update `config/cart.php`:
```php
'driver' => 'database', // or 'redis'
```
For the database driver, publish and run the migration:
```bash
php artisan cart:install
php artisan migrate
```
See the [Storage Drivers](README.md#storage-drivers) section of the README for full configuration options.
+18 -10
View File
@@ -1,6 +1,6 @@
{
"name": "wearepixel/laravel-cart",
"version": "1.0.11",
"version": "3.0.0",
"description": "A cart implementation for Laravel",
"keywords": [
"laravel",
@@ -15,26 +15,34 @@
},
{
"name": "Joel Male",
"email": "joel@wearepixel.com.au"
"email": "joel@joelmale.com"
}
],
"require": {
"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"
"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"
},
"require-dev": {
"mockery/mockery": "^1.4.2",
"laravel/pint": "^1.17",
"pestphp/pest": "2.35.1",
"larapack/dd": "1.1"
"mockery/mockery": "^2.0",
"laravel/pint": "^1.21",
"pestphp/pest": "^4.7",
"larapack/dd": "dev-master",
"phpstan/phpstan": "2.2.x-dev"
},
"autoload": {
"psr-4": {
"Wearepixel\\Cart\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev",
"extra": {
"laravel": {
Generated
+4063 -1567
View File
File diff suppressed because it is too large Load Diff
+21 -4
View File
@@ -14,15 +14,32 @@
/*
* ---------------------------------------------------------------
* Persistence
* Storage
* ---------------------------------------------------------------
* Supported: "session", "database", "redis", "null", "multi"
*/
'storage' => null,
'driver' => 'session',
'storage' => [
'database' => [
'model' => '',
'id' => '',
'items' => '',
'conditions' => '',
],
],
/*
* ---------------------------------------------------------------
* Events
* Driver-specific options
* ---------------------------------------------------------------
*/
'events' => null,
'drivers' => [
'redis' => [
'connection' => 'default',
'ttl' => 604800, // 7 days in seconds
],
'multi' => ['session', 'database'],
],
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+42
View File
@@ -0,0 +1,42 @@
<?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
@@ -0,0 +1,14 @@
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
+6 -7
View File
@@ -1,17 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<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>
<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">
<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>
+334 -451
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -78,6 +78,14 @@ public function getMaximum(): int|float|null
return $this->args['maximum'] ?? null;
}
/**
* Get the number of items this condition applies to (null = all items)
*/
public function getAppliesToQuantity(): int|null
{
return isset($this->args['applies_to']) ? (int) $this->args['applies_to'] : null;
}
/**
* get the additional attributes of a condition
*
@@ -105,13 +113,11 @@ public function setValue($value): void
}
/**
* Set the order to apply this condition. If no argument order is applied we return 0 as
* indicator that no assignment has been made
* Set the order to apply this condition.
*
* @param int $order
* @return int
*/
public function setOrder($order = 1)
public function setOrder($order = 1): void
{
$this->args['order'] = $order;
}
@@ -139,8 +145,6 @@ public function applyCondition($totalOrSubTotalOrPrice)
/**
* get the calculated value of this condition supplied by the subtotal|price
*
* @return mixed
*/
public function getCalculatedValue($totalOrSubTotalOrPrice = null): int|float
{
+72
View File
@@ -0,0 +1,72 @@
<?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);
}
}
+27 -42
View File
@@ -1,68 +1,53 @@
<?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
{
/**
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Boot the service provider.
*/
public function boot()
public function boot(): void
{
if ($this->app->runningInConsole()) {
$this->publishes([
__DIR__ . '/../config/config.php' => config_path('cart.php'),
], 'config');
], 'cart-config');
$this->commands([
InstallCommand::class,
MakeCouponCommand::class,
MakeTaxCommand::class,
MakeShippingCommand::class,
MakeDriverCommand::class,
DebugCommand::class,
]);
}
}
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'cart');
$this->app->singleton('cart.manager', fn($app) => new CartManager($app));
$this->app->singleton('cart', function ($app) {
$storageClass = config('cart.storage');
$eventsClass = config('cart.events');
$config = config('cart');
$events = $app['events'];
$driver = $app['cart.manager']->driver();
$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')
);
return new Cart($driver, $events, 'cart', $config);
});
}
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
public function provides(): array
{
return [];
}
+67
View File
@@ -0,0 +1,67 @@
<?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
@@ -0,0 +1,41 @@
<?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
@@ -0,0 +1,75 @@
<?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
@@ -0,0 +1,124 @@
<?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
@@ -0,0 +1,75 @@
<?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
@@ -0,0 +1,75 @@
<?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
@@ -0,0 +1,62 @@
<?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
@@ -0,0 +1,22 @@
<?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
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Contracts;
use Wearepixel\Cart\CartCondition;
interface CartConditionable
{
public function toCondition(): CartCondition;
}
+53
View File
@@ -0,0 +1,53 @@
<?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
@@ -0,0 +1,28 @@
<?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
@@ -0,0 +1,82 @@
<?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
@@ -0,0 +1,84 @@
<?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
@@ -0,0 +1,67 @@
<?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
@@ -0,0 +1,102 @@
<?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
@@ -0,0 +1,91 @@
<?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;
}
}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidCouponException extends \Exception {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidShippingRateException extends \Exception {}
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
namespace Wearepixel\Cart\Exceptions;
class InvalidTaxRuleException extends \Exception {}
+59 -1
View File
@@ -3,10 +3,68 @@
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()
protected static function getFacadeAccessor(): string
{
return 'cart';
}
+70 -7
View File
@@ -5,6 +5,14 @@
use Illuminate\Support\Collection;
use Wearepixel\Cart\Helpers\Helpers;
/**
* @property mixed $id
* @property string $name
* @property float|int $price
* @property int|float $quantity
* @property array<string, mixed> $attributes
* @property mixed $conditions
*/
class ItemCollection extends Collection
{
/**
@@ -46,9 +54,9 @@ public function __get($name)
/**
* return the associated model of an item
*
* @return bool
* @return mixed
*/
protected function getAssociatedModel()
protected function getAssociatedModel(): mixed
{
if (! $this->has('associatedModel')) {
return null;
@@ -91,7 +99,37 @@ public function getConditions()
return [];
}
return $this['conditions'];
$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;
}
/**
@@ -108,14 +146,14 @@ public function getPriceWithConditions($formatted = true)
$processed = 0;
if ($this->hasConditions()) {
if (is_array($this->conditions)) {
foreach ($this->conditions as $condition) {
if (is_array($this->getConditions())) {
foreach ($this->getConditions() as $condition) {
($processed > 0) ? $toBeCalculated = $newPrice : $toBeCalculated = $originalPrice;
$newPrice = $condition->applyCondition($toBeCalculated);
$processed++;
}
} else {
$newPrice = $this['conditions']->applyCondition($originalPrice);
$newPrice = $this->getConditions()->applyCondition($originalPrice);
}
return Helpers::formatValue($newPrice, $formatted, $this->config);
@@ -132,6 +170,31 @@ public function getPriceWithConditions($formatted = true)
*/
public function getPriceSumWithConditions($formatted = true)
{
return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config);
$conditions = $this->hasConditions() ? $this->getConditions() : [];
$conditions = is_array($conditions) ? $conditions : [$conditions];
$hasLimit = collect($conditions)->some(fn ($c) => $c->getAppliesToQuantity() !== null);
if (! $hasLimit) {
return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config);
}
$total = 0.0;
for ($i = 1; $i <= $this->quantity; $i++) {
$price = $this->price;
foreach ($conditions as $condition) {
$limit = $condition->getAppliesToQuantity();
if ($limit === null || $i <= $limit) {
$price = $condition->applyCondition($price);
}
}
$total += $price;
}
return Helpers::formatValue($total, $formatted, $this->config);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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
@@ -0,0 +1,48 @@
<?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
@@ -0,0 +1,38 @@
<?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,6 +7,9 @@
use Illuminate\Translation\FileLoader;
use Illuminate\Translation\Translator;
/**
* @method static \Illuminate\Validation\Validator make(array $data, array $rules, array $messages = [], array $attributes = [])
*/
abstract class Validator
{
protected static $factory;
+1 -2
View File
@@ -4,6 +4,5 @@
'format_numbers' => true,
'decimals' => 2,
'dec_point' => '.',
'thousands_sep' => ',',
'use_cents' => false,
'driver' => 'session',
];
+17
View File
@@ -0,0 +1,17 @@
<?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,4 +4,5 @@
'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' => ',',
'thousands_sep' => '.',
'driver' => 'session',
];
+23
View File
@@ -0,0 +1,23 @@
<?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,4 +20,9 @@ public function put($key, $value)
{
$this->session[$key] = $value;
}
public function forget($key)
{
unset($this->session[$key]);
}
}
+2 -26
View File
@@ -1,29 +1,5 @@
<?php
/*
|--------------------------------------------------------------------------
| 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.
|
*/
use Tests\TestCase;
// 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);
});
uses(TestCase::class)->in(__DIR__);
+44 -2
View File
@@ -1,10 +1,52 @@
<?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;
abstract class TestCase extends BaseTestCase
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();
});
}
}
+8 -8
View File
@@ -3,6 +3,7 @@
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\CartConditionCollection;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -10,11 +11,10 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock(),
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require(__DIR__ . '/../Helpers/ConfigConditionsMock.php')
require (__DIR__ . '/../Helpers/ConfigConditionsMock.php')
);
});
@@ -950,8 +950,8 @@
expect($this->cart->getCalculatedValueForCondition('Coupon Discount'))->toEqual(200.0, 'Coupon Discount should be 200.0');
expect($this->cart->getCalculatedValueForCondition('Gift Card'))->toEqual(0, 'Gift Card should be 0');
expect($couponDiscount->getCalculatedValue())->toEqual(200.0, 'Coupon Discount value should be 200.0');
expect($giftCard->getCalculatedValue())->toEqual(0, 'Gift Card value should be 0');
expect($this->cart->getCondition('Coupon Discount')->getCalculatedValue(200.0))->toEqual(200.0, 'Coupon Discount value should be 200.0');
expect($this->cart->getCondition('Gift Card')->getCalculatedValue(0))->toEqual(0, 'Gift Card value should be 0');
});
test('calculate the subtotal correctly when one condition makes it $0, and the other adds $10', function () {
@@ -1065,7 +1065,7 @@
expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90');
$this->cart->clear();
$this->cart->clearItems();
$this->cart->add([
'id' => 1,
@@ -1099,7 +1099,7 @@
expect($this->cart->getTotal())->toEqual(90, 'Cart should have total of 90');
$this->cart->clear();
$this->cart->clearItems();
$this->cart->add([
'id' => 1,
@@ -1201,7 +1201,7 @@
expect($this->cart->getConditions(active: true))->toHaveCount(0, 'Cart should have 0 conditions');
$this->cart->clear();
$this->cart->clearItems();
$this->cart->add([
'id' => 1,
+100
View File
@@ -0,0 +1,100 @@
<?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);
});
});
+14 -2
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -9,10 +10,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
@@ -689,3 +689,15 @@
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');
});
+33 -38
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {});
@@ -9,15 +10,14 @@
Mockery::close();
});
test('event cart created', function () {
test('event cart Created', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher');
$events->shouldReceive('dispatch')->once()->with('cart.created', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('LaravelCart.Created', Mockery::type('array'), true);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -25,16 +25,15 @@
});
test('event cart adding', function () {
$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);
$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);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -44,16 +43,15 @@
});
test('event cart adding multiple times', function () {
$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);
$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);
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -64,10 +62,10 @@
});
test('event cart adding multiple times scenario two', function () {
$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 = 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);
$items = [
[
@@ -94,10 +92,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -107,12 +104,12 @@
});
test('event cart remove item', function () {
$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);
$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);
$items = [
[
@@ -139,10 +136,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
@@ -154,12 +150,12 @@
});
test('event cart clear', function () {
$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);
$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);
$items = [
[
@@ -186,10 +182,9 @@
];
$cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
+3 -4
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -8,18 +9,16 @@
$events->shouldReceive('dispatch');
$this->cart1 = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'uniquesessionkey123'),
$events,
'shopping',
'uniquesessionkey123',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
$this->cart2 = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'uniquesessionkey456'),
$events,
'wishlist',
'uniquesessionkey456',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+71
View File
@@ -0,0 +1,71 @@
<?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');
});
});
@@ -0,0 +1,33 @@
<?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);
}
});
});
@@ -0,0 +1,30 @@
<?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)');
});
});
@@ -0,0 +1,34 @@
<?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');
});
});
@@ -0,0 +1,30 @@
<?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'");
});
});
@@ -0,0 +1,30 @@
<?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
@@ -0,0 +1,87 @@
<?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);
});
});
@@ -0,0 +1,54 @@
<?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
@@ -0,0 +1,72 @@
<?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
@@ -0,0 +1,181 @@
<?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
@@ -0,0 +1,79 @@
<?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
@@ -0,0 +1,84 @@
<?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
@@ -0,0 +1,64 @@
<?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
@@ -0,0 +1,94 @@
<?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,6 +2,7 @@
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -10,10 +11,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php')
);
});
+2 -2
View File
@@ -1,6 +1,7 @@
<?php
use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {
@@ -8,10 +9,9 @@
$events->shouldReceive('dispatch');
$this->cart = new Cart(
new SessionMock,
new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events,
'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMockOtherFormat.php')
);
});
+159
View File
@@ -0,0 +1,159 @@
<?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
@@ -0,0 +1,68 @@
<?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
@@ -0,0 +1,68 @@
<?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
@@ -0,0 +1,82 @@
<?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
@@ -0,0 +1,112 @@
<?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
@@ -0,0 +1,14 @@
<?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);