Compare commits

...
Author SHA1 Message Date
dependabot[bot] 71722993c6 chore(deps): bump the actions group across 1 directory with 2 updates
Bumps the actions group with 2 updates in the / directory: [actions/checkout](https://github.com/actions/checkout) and [shivammathur/setup-php](https://github.com/shivammathur/setup-php).


Updates `actions/checkout` from 6.0.3 to 7.0.1
- [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/df4cb1c069e1874edd31b4311f1884172cec0e10...3d3c42e5aac5ba805825da76410c181273ba90b1)

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

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: shivammathur/setup-php
  dependency-version: 2.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-09 21:44:57 +00:00
github-actions[bot] 8f99b148d6 Merge pull request #17 from wearepixel/dependabot/composer/php-minor-patch-3dfb8bd77a
chore(deps-dev): bump laravel/pint from 1.29.1 to 1.32.1 in the php-minor-patch group
2026-11-03 21:45:14 +00:00
github-actions[bot] 6bd569cca7 Merge pull request #18 from wearepixel/dependabot/composer/phpstan/phpstan-2.3.4
chore(deps-dev): bump phpstan/phpstan from 2.2.x-dev to 2.3.4
2026-11-03 21:45:07 +00:00
dependabot[bot] 63d310bf7e chore(deps-dev): bump phpstan/phpstan from 2.2.x-dev to 2.3.4
Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.2.x-dev to 2.3.4.
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.3.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-03 21:44:19 +00:00
dependabot[bot] c9163a7d78 chore(deps-dev): bump laravel/pint
Bumps the php-minor-patch group with 1 update: [laravel/pint](https://github.com/laravel/pint).


Updates `laravel/pint` from 1.29.1 to 1.32.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.29.1...v1.32.1)

---
updated-dependencies:
- dependency-name: laravel/pint
  dependency-version: 1.32.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-11-03 21:43:59 +00:00
Joel Male 45ef72894b fix: correct custom driver registration hint in cart:make:driver
The command told users to register their driver in config/cart.php
under "drivers", but CartManager has no such registration point for
custom drivers. The actual mechanism is Cart::extend() called from a
service provider's boot() method.
2026-08-04 12:06:58 +10:00
Joel Male c6ec19121c docs: link README to hosted docs and correct supported Laravel versions
Adds a Spatie-style inline link to the extensive documentation and
points the Documentation section at the versioned v3 introduction
page. Also corrects the supported Laravel range (11-13, not 10-13)
to match the illuminate/* constraints in composer.json.
2026-08-04 12:06:57 +10:00
Joel Male c8baf7f0a8 docs: point to the new hosted documentation site 2026-08-02 15:42:55 +10:00
pixel-workflows[bot] 0ea24eb419 chore: sync files from wearepixel/pixel-workflows - synced local 'pint.json' with remote 'files/pint.json' 2026-07-27 08:21:51 +00:00
pixel-workflows[bot] 917d530006 chore: sync files from wearepixel/pixel-workflows - created local '.gitattributes' from remote 'files/.gitattributes' 2026-07-27 08:21:48 +00:00
pixel-workflows[bot] d56ce61dec chore: sync files from wearepixel/pixel-workflows - created local '.editorconfig' from remote 'files/.editorconfig' 2026-07-27 08:21:45 +00:00
pixel-workflows[bot] 11d734e848 chore: sync files from wearepixel/pixel-workflows - created local '.github/workflows/dependabot-slack-alert.yml' from remote 'files/.github/workflows/dependabot-slack-alert.yml' 2026-07-27 08:21:42 +00:00
pixel-workflows[bot] 07d155159d chore: sync files from wearepixel/pixel-workflows - synced local '.github/workflows/dependabot-auto-merge.yml' with remote 'files/.github/workflows/dependabot-auto-merge.yml' 2026-07-27 08:21:39 +00:00
pixel-workflows[bot] 45362d3c62 chore: update dependabot configuration 2026-07-27 08:20:56 +00:00
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
Joel Male b3ef4c38fc 1.0.11 2024-09-23 07:48:27 +10:00
Joel Male a45121ca63 Merge pull request #20 from wearepixel/task/fix-subtotal-always-being-used-if-the-total-is-$0
Task: Fix subtotal always being used when cart conditions on the total bring it to $0
2024-09-23 07:48:06 +10:00
Joel Male 5505f121c7 fix subtotal always being used 2024-09-23 07:45:59 +10:00
Joel Male 472d505041 wip: fix issue with conditions not being properly removed 2024-09-18 15:56:12 +10:00
Joel Male c6c4240c5f 1.0.9 2024-09-08 18:27:44 +10:00
Joel Male 54d7c8a54f Merge pull request #17 from wearepixel/feature/livewire-support-for-nested-collections
Better Livewire Support
2024-09-08 12:23:22 +10:00
Joel Male a8d743eb9b remove php formatting 2024-09-08 12:22:03 +10:00
Pixel Tech b7381573e8 Apply Laravel Pint
Signed-off-by: Pixel Tech <tech@wearepixel.com.au>
2024-09-08 02:21:01 +00:00
Joel Male d4d4df7d58 pint fixes 2024-09-08 12:20:36 +10:00
Pixel Tech 6ea2cb3b8b Apply Laravel Pint
Signed-off-by: Pixel Tech <tech@wearepixel.com.au>
2024-09-07 22:36:00 +00:00
Joel Male 7a2c41e02d add better livewire support
- and reset collection indexes properly
2024-09-08 08:32:51 +10:00
Joel Male 9a59857825 Merge pull request #12 from wearepixel/feature/targets-to-activate-cart-conditions
Feature: Add targets to cart conditions
2024-09-04 09:38:34 +10:00
Joel Male 43238b6f2e revert php 8.0 support 2024-09-04 09:33:49 +10:00
Joel Male c0f0fcd2f0 fix 8.0 tests 2024-09-04 09:30:28 +10:00
Joel Male 185c4572ae fix tests 2024-09-04 09:26:50 +10:00
Pixel Tech c111cda3aa Apply Laravel Pint
Signed-off-by: Pixel Tech <tech@wearepixel.com.au>
2024-09-03 23:16:53 +00:00
Joel Male ab9eb6c873 fix tests 2024-09-04 09:16:15 +10:00
Joel Male 7ee26add8d undo extra changes 2024-09-04 09:14:23 +10:00
Joel Male 05318b7b13 working on deprecations and code cleanup 2024-09-03 21:41:45 +10:00
Joel Male 4d2efcdeae fix tests for cart conditions 2024-09-03 21:07:58 +10:00
Joel Male a269c188e4 initial functionality 2024-09-03 20:22:03 +10:00
Joel Male 2ccba20119 1.0.8 2024-08-30 10:08:47 +10:00
Joel Male 313a4f7aa2 Revert "1.0.7"
This reverts commit 4a312a37e2.
2024-08-30 10:08:27 +10:00
Joel Male 4a312a37e2 1.0.7 2024-08-30 09:44:40 +10:00
github-actions[bot] 274a8504af Merge pull request #10 from wearepixel/dependabot/composer/master/pestphp/pest-2.35.1
Bump pestphp/pest from 2.35.0 to 2.35.1
2024-08-26 12:56:01 +00:00
dependabot[bot] 4f22672ca5 Bump pestphp/pest from 2.35.0 to 2.35.1
Bumps [pestphp/pest](https://github.com/pestphp/pest) from 2.35.0 to 2.35.1.
- [Release notes](https://github.com/pestphp/pest/releases)
- [Changelog](https://github.com/pestphp/pest/blob/2.x/RELEASE.md)
- [Commits](https://github.com/pestphp/pest/compare/v2.35.0...v2.35.1)

---
updated-dependencies:
- dependency-name: pestphp/pest
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-26 12:55:45 +00:00
Joel Male 61475a7060 1.0.6 2024-08-26 10:58:14 +10:00
Joel Male 6604cf984d fix tests & validation errors 2024-08-26 10:57:02 +10:00
Joel Male 7d955d722c Merge pull request #9 from wearepixel/task/add-items-with-no-price
Add items with no price
2024-08-26 10:49:47 +10:00
Joel Male 54d94d8d12 Add items with no price 2024-08-26 10:38:20 +10:00
Joel Male e49ebe02e0 1.0.4 2024-08-20 16:58:57 +10:00
Joel Male 28b451583d Merge pull request #8 from wearepixel/issue/cart-conditions-break-if-one-makes-the-subtotal-free-and-another-adds-to-the-subtotal
Cart conditions break if one makes the subtotal free and another adds to the subtotal
2024-08-20 16:58:24 +10:00
Joel Male 3673f360b7 feat: fix tests 2024-08-20 16:57:11 +10:00
Joel Male f6447ad4d9 feat: add failing tests 2024-08-20 16:57:06 +10:00
Joel Male b102dd2356 1.0.3 2024-08-19 10:35:54 +10:00
Joel Male 25fc662c05 formatting 2024-08-16 19:23:01 +10:00
Joel Male 57328c2f64 legal jargon 2024-08-16 19:23:01 +10:00
github-actions[bot] 4d19211f2c Merge pull request #5 from wearepixel/dependabot/composer/master/pestphp/pest-2.35.0
Bump pestphp/pest from 2.34.4 to 2.35.0
2024-08-16 09:20:37 +00:00
dependabot[bot] 8cee4f6208 Bump pestphp/pest from 2.34.4 to 2.35.0
Bumps [pestphp/pest](https://github.com/pestphp/pest) from 2.34.4 to 2.35.0.
- [Release notes](https://github.com/pestphp/pest/releases)
- [Changelog](https://github.com/pestphp/pest/blob/2.x/RELEASE.md)
- [Commits](https://github.com/pestphp/pest/compare/v2.34.4...v2.35.0)

---
updated-dependencies:
- dependency-name: pestphp/pest
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-08-16 09:20:26 +00:00
Joel Male 0ec0668bcf code cleanup 2024-08-16 19:19:14 +10:00
Joel Male 9a4b5382bd 1.0.2 2024-08-16 16:55:10 +10:00
Joel Male 95054f41a4 feature: quantities now support decimals
Merge pull request #4 from wearepixel/feature/quantities-now-support-decimals
2024-08-16 16:52:13 +10:00
Joel Male 0a9bcc2832 quantities now support decimals (so adding 1.1, or setting it to 1.1) 2024-08-16 16:49:59 +10:00
Joel Male 8c8c90c170 Feature: Cart item orders never change
Merge pull request #2 from wearepixel/feature/cart-item-orders-never-change
2024-08-16 16:33:26 +10:00
Pixel Tech 18c753f39b Apply Laravel Pint
Signed-off-by: Pixel Tech <tech@wearepixel.com.au>
2024-08-16 06:33:03 +00:00
Joel Male d12a566366 cart item orders never change 2024-08-16 16:29:46 +10:00
Joel Male 66fd4c9099 v1.0.1 2024-08-16 15:30:57 +10:00
Joel Male d48f54cf16 move to round() instead of number_format() 2024-08-16 15:06:33 +10:00
Joel Male 24f64e712c code cleanup 2024-08-16 14:55:54 +10:00
Joel Male 85ae0c30c5 test updates & working on calculating condition values based on orders 2024-08-15 17:21:19 +10:00
89 changed files with 10467 additions and 3786 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+24 -3
View File
@@ -1,17 +1,38 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
target-branch: "develop"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "monday"
open-pull-requests-limit: 10
groups:
actions:
applies-to: version-updates
patterns: ["*"]
commit-message:
prefix: "chore"
include: "scope"
labels: labels:
- "dependencies" - "dependencies"
- package-ecosystem: "composer" - package-ecosystem: "composer"
directory: "/" directory: "/"
target-branch: "develop"
schedule: schedule:
interval: "weekly" interval: "weekly"
day: "tuesday"
open-pull-requests-limit: 10
groups:
php-minor-patch:
applies-to: version-updates
update-types:
- "minor"
- "patch"
php-security:
applies-to: security-updates
patterns: ["*"]
commit-message:
prefix: "chore"
include: "scope"
labels: labels:
- "dependencies" - "dependencies"
+49 -22
View File
@@ -1,32 +1,59 @@
name: dependabot-auto-merge name: Dependabot Auto-merge
on: pull_request_target
on:
workflow_run:
workflows: ["Tests"]
types: [completed]
permissions: permissions:
pull-requests: write
contents: write contents: write
pull-requests: write
jobs: jobs:
dependabot: auto-merge:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ github.actor == 'dependabot[bot]' }} if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.actor.login == 'dependabot[bot]'
steps: steps:
- name: Find PR and check update type
- name: Dependabot metadata id: check
id: metadata
uses: dependabot/fetch-metadata@v2.2.0
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge Dependabot PRs for semver-minor updates
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-minor'}}
run: gh pr merge --auto --merge "$PR_URL"
env: env:
PR_URL: ${{github.event.pull_request.html_url}} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
run: |
PR_DATA=$(gh pr list --repo "${{ github.repository }}" --head "$HEAD_BRANCH" --json number,title,url,author --jq '.[0]')
- name: Auto-merge Dependabot PRs for semver-patch updates PR_URL=$(echo "$PR_DATA" | jq -r '.url')
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}} PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login')
run: gh pr merge --auto --merge "$PR_URL" PR_TITLE=$(echo "$PR_DATA" | jq -r '.title')
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
if [ "$PR_AUTHOR" != "app/dependabot" ]; then
echo "is_safe=false" >> $GITHUB_OUTPUT
echo "Not a Dependabot PR, skipping."
exit 0
fi
# Parse major version numbers from title: "bump X from A[.B.C] to D[.E.F]"
# Works for both "from 4.0.0 to 4.1.0" and "from 4 to 6" (GitHub Actions)
FROM_MAJOR=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+')
TO_MAJOR=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+')
echo "PR: $PR_TITLE (major: $FROM_MAJOR -> $TO_MAJOR)"
if [ "$FROM_MAJOR" = "$TO_MAJOR" ]; then
echo "is_safe=true" >> $GITHUB_OUTPUT
else
echo "is_safe=false" >> $GITHUB_OUTPUT
echo "Major version bump, skipping auto-merge."
fi
- name: Merge minor and patch updates
if: steps.check.outputs.is_safe == 'true'
run: gh pr merge --merge "${{ steps.check.outputs.pr_url }}"
env: env:
PR_URL: ${{github.event.pull_request.html_url}} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@@ -0,0 +1,98 @@
name: Dependabot Slack Alert
on:
pull_request_target:
types: [opened]
jobs:
notify:
name: Notify Slack of major update
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Check if major version bump
id: check
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: |
from_major=$(echo "$PR_TITLE" | grep -oP 'from \K[0-9]+' | head -1)
to_major=$(echo "$PR_TITLE" | grep -oP 'to \K[0-9]+' | head -1)
if [ -n "$from_major" ] && [ -n "$to_major" ] && [ "$from_major" != "$to_major" ]; then
echo "is_major=true" >> $GITHUB_OUTPUT
echo "Major bump detected: $from_major -> $to_major"
else
echo "is_major=false" >> $GITHUB_OUTPUT
echo "Minor/patch bump, skipping"
fi
- name: Send Slack alert
if: steps.check.outputs.is_major == 'true'
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
run: |
if [ -z "$SLACK_WEBHOOK_URL" ]; then
echo "SLACK_WEBHOOK_URL not configured for this repo, skipping"
exit 0
fi
payload=$(jq -n \
--arg repo "$REPO" \
--arg title "$PR_TITLE" \
--arg url "$PR_URL" \
--arg pr "#$PR_NUMBER" \
'{
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: ":dependabot: *Major dependency update requires review*"
}
},
{
type: "section",
fields: [
{type: "mrkdwn", text: ("*Repository*\n`" + $repo + "`")},
{type: "mrkdwn", text: ("*Pull Request*\n<" + $url + "|" + $pr + ">") }
]
},
{
type: "section",
text: {
type: "mrkdwn",
text: $title
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: "Major version bumps are not auto-merged as they may contain breaking changes."
}
]
},
{
type: "actions",
elements: [
{
type: "button",
text: {type: "plain_text", text: "Review PR"},
url: $url,
style: "primary"
}
]
}
]
}')
curl -s -o /dev/null -w "%{http_code}" -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-type: application/json" \
--data "$payload" | grep -q "^200$" \
&& echo "Slack notification sent" \
|| echo "Slack notification failed"
-25
View File
@@ -1,25 +0,0 @@
name: PHP Formatting
on: pull_request
jobs:
php-formatting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: "Run Pint"
uses: aglipanci/laravel-pint-action@latest
with:
verboseMode: true
configPath: "pint.json"
pintVersion: 1.17.2
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: "Apply Laravel Pint"
commit_options: '--no-verify --signoff'
file_pattern: "*.php"
commit_user_name: "Pixel Tech"
commit_user_email: "tech@wearepixel.com.au"
commit_author: "Pixel Tech<tech@wearepixel.com.au>"
+9 -6
View File
@@ -9,19 +9,19 @@ on:
jobs: jobs:
tests: tests:
name: Tests name: Tests
runs-on: ubuntu-20.04 runs-on: ubuntu-latest
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
php: ['8.1','8.2', '8.3'] php: ["8.3", "8.4", "8.5"]
stability: [prefer-stable] stability: [prefer-stable]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup PHP - name: Setup PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # v2
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, memcached
@@ -29,12 +29,15 @@ jobs:
coverage: none coverage: none
- name: Install dependencies - name: Install dependencies
uses: nick-fields/retry@v3 uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with: with:
timeout_minutes: 5 timeout_minutes: 5
max_attempts: 5 max_attempts: 5
command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress ${{ matrix.flags }} command: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --no-progress ${{ matrix.flags }}
- name: Execute tests - 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
View File
@@ -3,3 +3,6 @@
.idea .idea
.phpunit.result.cache .phpunit.result.cache
READMETODO.md READMETODO.md
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) The MIT License (MIT)
Copyright (c) Pixel <hello@wearepixel.com.au> Copyright (c) Joel Male <joel@joelmale.com>
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+17 -472
View File
@@ -1,491 +1,36 @@
<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) [![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) [![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) [![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) [![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. A Cart Implementation for Laravel. To learn all about it, head over to the [extensive documentation](https://wearepixel.com.au/docs/laravel-cart/v3/introduction).
Supported Laravel Versions: 9, 10, 11 Supported Laravel Versions: 11, 12, and 13.
## Quick Demo For Laravel 10.0 and below, please use version [1.0](https://github.com/wearepixel/laravel-cart/releases/tag/1.0.12)
```php > **Upgrading from v2?** See the [Upgrade Guide](https://wearepixel.com.au/docs/laravel-cart/v3/upgrade-guide) for breaking changes and recommended v3 patterns.
// Add an item to the cart
\Cart::add(1, 'Product 1', 19.99, 2, ['size' => 'large']);
// Get the cart contents ## Installation
$cartContents = \Cart::getContent();
// Update item quantity by its id
\Cart::update(1, ['quantity' => 3]);
// Remove an item from the cart by its id
\Cart::remove(1);
// Get cart total
\$total = Cart::getTotal();
// Clear the entire cart
\Cart::clear();
```
## Installing
Install the package through [Composer](http://getcomposer.org/). Install the package through [Composer](http://getcomposer.org/).
`composer require "wearepixel/laravel-cart"` ```bash
composer require wearepixel/laravel-cart
## Configuration
You can publish the configuration file to customize various options.
```php
php artisan vendor:publish --provider="Wearepixel\Cart\CartServiceProvider" --tag="config"
``` ```
## HOW TO USE ## Documentation
- [Usage](#usage) Full documentation, including configuration, usage, conditions, storage drivers, testing, and the v2 upgrade guide, is available at [wearepixel.com.au/docs/laravel-cart](https://wearepixel.com.au/docs/laravel-cart/v3/introduction).
- [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)
## Usage ## 🫡 Credits
The cart has a default sessionKey that holds the cart data and stores it in the session. 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.
This ## 📓 License
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. The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
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']);
```
### Adding to the cart: **Cart::add()**
There are a few ways to add items to the cart.
```php
// Add a simple product to the cart
Cart::add(
455, # product id
'Sample Item', # product name
100.99, # product price
2, # quantity
[] # optional attributes
);
// array format
Cart::add([
456, # product id
'Leather Shoes', # product name
187, # product price
1, # quantity
[] # optional attributes
]);
// add an item with attributes
Cart::add([
457, // product id
'T-Shirt', // product name
29.99, // product price
1, // quantity
[
'size' => 'L',
'color' => 'Blue'
] // attributes
]);
// add an item with conditions
Cart::add([
458, // product id
'Headphones', // product name
199.99, // product price
1, // quantity
[], // attributes
[
[
'name' => '10% Off',
'type' => 'discount',
'value' => '-10%'
]
] // conditions
]);
// add multiple items at one time
Cart::add(
[
456, # product id
'Leather Shoes', # product name
187, # product price
1, # quantity
[] # optional attributes
],
[
431, # product id
'Leather Jacket', # product name
254.50, # product price
1, # quantity
[] # optional attributes
]
);
```
### 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
]
);
// updating a product's quantity
Cart::update(
456, # product id
[
'quantity' => 2, // by default adding the quantity (so if from 4 to 6)
]
);
// reducing it...
Cart::update(
456,
[
'quantity' => -1, // so if from 4 to 3
]
);
// you can replace the quantity by setting relative to false
Cart::update(
456, # product id
[
'quantity' => [
'relative' => false,
'value' => 5 // if the quantity was 2, it will now be 5
],
]
);
```
### 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()**
```php
// Get an item from the cart by its id
Cart::get(456);
// You can also get the total price of the item
$summedPrice = Cart::get($itemId)->getPriceSum();
```
### Getting the cart content: **Cart::getContent()**
```php
// Returns a collection of the cart's contents
$cartData = Cart::getContent();
// Gets the total number of items (not quantity) in the cart
$cartCollection->count();
// Transform the collection to an array or a JSON
$cartCollection->toArray();
$cartCollection->toJson();
```
Get cart total quantity: **Cart::getTotalQuantity()**
```php
$cartTotalQuantity = Cart::getTotalQuantity();
```
### Cart subtotal: **Cart::getSubTotal()**
```php
$subTotal = Cart::getSubTotal();
```
### Cart subtotal without conditions: **Cart::getSubTotalWithoutConditions()**
```php
$subTotalWithoutConditions = Cart::getSubTotalWithoutConditions();
```
Cart Total: **Cart::getTotal()**
```php
$total = Cart::getTotal();
```
### Check if cart is empty: **Cart::isEmpty()**
```php
Cart::isEmpty();
```
### Clearing the Cart: **Cart::clear()**
```php
Cart::clear();
```
## Conditions
Conditions are very useful in terms for adding things like discounts, taxes, shipping costs, etc.
Conditions can be added on the entire cart or on individual items.
Conditions on a cart level should always have a `target` of `subtotal` or `total`. This tells the cart which value to apply the condition to.
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
#### Adding a condition to the cart: **Cart::condition()**
```php
// Add a single condition to the cart
$condition = new \Wearepixel\Cart\CartCondition([
'name' => 'Tax: 10%',
'type' => 'tax',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '10%',
'attributes' => [ // add extra attributes here
'description' => 'Compulsory tax',
]
]);
Cart::condition($condition);
// Add multiple conditions
$tax = new \Wearepixel\Cart\CartCondition([
'name' => 'Tax: 10%',
'type' => 'tax',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '10%',
'order' => 2
]);
$shipping = new \Wearepixel\Cart\CartCondition([
'name' => 'Shipping: $15',
'type' => 'shipping',
'target' => 'subtotal', // this condition will be applied to cart's subtotal when getSubTotal() is called.
'value' => '+15',
'order' => 1
]);
Cart::condition($tax);
Cart::condition($shipping);
// or as an array
Cart::condition([$tax, $shipping]);
// add condition to only apply on totals, not in subtotal
$shipping = new \Wearepixel\Cart\CartCondition([
'name' => 'Express Shipping $15',
'type' => 'shipping',
'target' => 'total',
'value' => '+15',
'order' => 1
]);
Cart::condition($shipping);
```
#### Getting conditions on the cart: **Cart::getConditions()**
```php
// To get all applied conditions on a cart, use below:
$cartConditions = Cart::getConditions();
foreach($cartConditions as $condition)
{
$condition->getTarget(); // the target of which the condition was applied
$condition->getName(); // the name of the condition
$condition->getType(); // the type
$condition->getValue(); // the value of the condition
$condition->getOrder(); // the order of the condition
$condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added
}
// You can also get a condition that has been applied on the cart by using its name:
$condition = Cart::getCondition('Tax: 10%');
$condition->getTarget(); // the target of which the condition was applied
$condition->getName(); // the name of the condition
$condition->getType(); // the type
$condition->getValue(); // the value of the condition
$condition->getAttributes(); // the attributes of the condition, returns an empty [] if no attributes added
```
### Calculating condition value: **Cart::getCondition('Tax: 10%')->getCalculatedValue($subTotal)**
```php
$subTotal = Cart::getSubTotal();
$condition = Cart::getCondition('VAT 12.5%');
$conditionCalculatedValue = $condition->getCalculatedValue($subTotal);
```
### Condition on items
Item conditions are useful if you have discounts to be applied specifically on an item and not on the whole cart value.
```php
// lets create first our condition instance
$saleCondition = new \Wearepixel\Cart\CartCondition([
'name' => '50% Off',
'type' => 'tax',
'value' => '-50%',
]);
// Create the product data with the condition
$product = [
'id' => 456,
'name' => 'Sample Item 1',
'price' => 100,
'quantity' => 1,
'attributes' => [],
'conditions' => $saleCondition
];
// Now add the product to the cart
Cart::add($product);
// You can of course also do multiple conditions on an item
$saleCondition = new \Wearepixel\Cart\CartCondition([
'name' => 'SALE 5%',
'type' => 'sale',
'value' => '-5%',
]);
$discountCode = new CartCondition([
'name' => 'Discount Code',
'type' => 'promo',
'value' => '-25',
]);
$item = [
'id' => 456,
'name' => 'Sample Item 1',
'price' => 100,
'quantity' => 1,
'attributes' => [],
'conditions' => [$saleCondition, $discountCode]
];
Cart::add($item);
```
> 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.
```php
// the subtotal will be calculated based on the conditions added that has target => "subtotal"
// and also conditions that are added on per item
$cartSubTotal = Cart::getSubTotal();
```
#### Add condition to existing Item on the cart: **Cart::addItemCondition($productId, $itemCondition)**
Adding Condition to an existing Item on the cart is simple as well.
```php
$condition = new CartCondition([
'name' => 'COUPON 101',
'type' => 'coupon',
'value' => '-5%',
]);
Cart::addItemCondition(456, $condition);
```
### Clearing Cart Conditions: **Cart::clearCartConditions()**
```php
Cart::clearCartConditions()
```
### Remove a specific cart condtion: **Cart::removeCartCondition($conditionName)**
```php
Cart::removeCartCondition('Summer Sale 5%')
```
### Remove a specific item condition: **Cart::removeItemCondition($itemId, $conditionName)**
```php
Cart::removeItemCondition(456, 'SALE 5%')
```
### Clear all item conditions: **Cart::clearItemConditions($itemId)**
```php
Cart::clearItemConditions(456)
```
### Get conditions by type: **Cart::getConditionsByType($type)**
This returns all conditions that has been added to the cart by the type specified.
```php
$tax = Cart::getConditionsByType('tax');
```
### Remove conditions by type: **Cart::removeConditionsByType($type)**
```php
Cart::removeConditionsByType('tax');
```
## Cart Items
The method **Cart::getContent()** returns a collection of items.
Apart from the above methods, you can also get the price of an item with or without **item level conditions** applied.
These methods do not apply cart level conditions.
```php
// With no conditions, just the price * quantity
$item->getPriceSum();
// With conditions applied get the price of a single quantity
$item->getPriceWithConditions();
// Get the sum with conditions applied
$item->getPriceSumWithConditions();
// Without conditions applied
$item->getPriceSumWithConditions();
```
## Credits
Huge credits to the original maintainer of the package, located at [darryldecode/laravelshoppingcart](https://github.com/darryldecode/laravelshoppingcart)
We have forked this package to update it to Laravel 11 and to add some features and support for newer PHP versions.
+11
View File
@@ -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 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "wearepixel/laravel-cart", "name": "wearepixel/laravel-cart",
"version": "1.0.0", "version": "3.0.0",
"description": "A cart implementation for Laravel", "description": "A cart implementation for Laravel",
"keywords": [ "keywords": [
"laravel", "laravel",
@@ -15,25 +15,34 @@
}, },
{ {
"name": "Joel Male", "name": "Joel Male",
"email": "joel@wearepixel.com.au" "email": "joel@joelmale.com"
} }
], ],
"require": { "require": {
"php": ">=8.0", "php": ">=8.3",
"illuminate/support": "^9.0|^10.0|^11.0", "illuminate/support": "^11.0|^12.0|^13.0",
"illuminate/validation": "^9.0|^10.0|^11.0", "illuminate/auth": "^11.0|^12.0|^13.0",
"illuminate/translation": "^9.0|^10.0|^11.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": { "require-dev": {
"mockery/mockery": "^1.4.2", "mockery/mockery": "^2.0",
"laravel/pint": "dev-main", "laravel/pint": "^1.21",
"pestphp/pest": "2.34.4" "pestphp/pest": "^4.7",
"larapack/dd": "dev-master",
"phpstan/phpstan": "2.2.x-dev"
}, },
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Wearepixel\\Cart\\": "src/" "Wearepixel\\Cart\\": "src/"
} }
}, },
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"minimum-stability": "dev", "minimum-stability": "dev",
"extra": { "extra": {
"laravel": { "laravel": {
Generated
+4164 -1544
View File
File diff suppressed because it is too large Load Diff
+25 -16
View File
@@ -3,34 +3,43 @@
return [ return [
/* /*
* --------------------------------------------------------------- * ---------------------------------------------------------------
* formatting * Formatting
* --------------------------------------------------------------- * ---------------------------------------------------------------
*
* the formatting of shopping cart values
*/ */
'format_numbers' => env('SHOPPING_FORMAT_VALUES', false), 'format_numbers' => env('LARAVEL_CART_FORMAT_VALUES', false),
'decimals' => env('SHOPPING_DECIMALS', 0), 'decimals' => env('LARAVEL_CART_DECIMALS', 0),
'dec_point' => env('SHOPPING_DEC_POINT', '.'), 'round_mode' => env('LARAVEL_CART_ROUND_MODE', 'down'),
'thousands_sep' => env('SHOPPING_THOUSANDS_SEP', ','),
/* /*
* --------------------------------------------------------------- * ---------------------------------------------------------------
* persistence * Storage
* --------------------------------------------------------------- * ---------------------------------------------------------------
* * Supported: "session", "database", "redis", "null", "multi"
* the configuration for persisting cart
*/ */
'storage' => null, 'driver' => 'session',
'storage' => [
'database' => [
'model' => '',
'id' => '',
'items' => '',
'conditions' => '',
],
],
/* /*
* --------------------------------------------------------------- * ---------------------------------------------------------------
* events * Driver-specific options
* --------------------------------------------------------------- * ---------------------------------------------------------------
*
* the configuration for cart events
*/ */
'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
-17
View File
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" bootstrap="tests/bootstrap.php" colors="true" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.5/phpunit.xsd">
<source ignoreIndirectDeprecations="true">
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
<testsuites>
<testsuite name="Laravel Cart Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<php>
<env name="DB_CONNECTION" value="testing"/>
</php>
</phpunit>
+2 -1
View File
@@ -7,6 +7,7 @@
"class_attributes_separation": false, "class_attributes_separation": false,
"ordered_imports": { "ordered_imports": {
"sort_algorithm": "length" "sort_algorithm": "length"
} },
"not_operator_with_space": false
} }
} }
+393 -513
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -4,6 +4,4 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
class CartCollection extends Collection class CartCollection extends Collection {}
{
}
+44 -14
View File
@@ -8,15 +8,12 @@
class CartCondition class CartCondition
{ {
/**
* @var array
*/
private $args; private $args;
/** /**
* the parsed raw value of the condition * the parsed raw value of the condition
*/ */
public $parsedRawValue; private $parsedRawValue = 0;
/** /**
* @param array $args (name, type, target, value) * @param array $args (name, type, target, value)
@@ -65,6 +62,30 @@ public function getType()
return $this->args['type']; return $this->args['type'];
} }
/**
* Get the minimum value of this condition
*/
public function getMinimum(): int|float|null
{
return $this->args['minimum'] ?? null;
}
/**
* Get the maximum value of this condition
*/
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 * get the additional attributes of a condition
* *
@@ -77,22 +98,26 @@ public function getAttributes()
/** /**
* the value of this the condition * the value of this the condition
*
* @return mixed
*/ */
public function getValue() public function getValue(): int|float|string
{ {
return $this->args['value']; return $this->args['value'];
} }
/** /**
* Set the order to apply this condition. If no argument order is applied we return 0 as * Set the value of this condition
* indicator that no assignment has been made */
public function setValue($value): void
{
$this->args['value'] = $value;
}
/**
* Set the order to apply this condition.
* *
* @param int $order * @param int $order
* @return int
*/ */
public function setOrder($order = 1) public function setOrder($order = 1): void
{ {
$this->args['order'] = $order; $this->args['order'] = $order;
} }
@@ -120,12 +145,12 @@ public function applyCondition($totalOrSubTotalOrPrice)
/** /**
* get the calculated value of this condition supplied by the subtotal|price * get the calculated value of this condition supplied by the subtotal|price
*
* @return mixed
*/ */
public function getCalculatedValue($totalOrSubTotalOrPrice) public function getCalculatedValue($totalOrSubTotalOrPrice = null): int|float
{ {
if ($totalOrSubTotalOrPrice) {
$this->apply($totalOrSubTotalOrPrice, $this->getValue()); $this->apply($totalOrSubTotalOrPrice, $this->getValue());
}
return $this->parsedRawValue; return $this->parsedRawValue;
} }
@@ -245,4 +270,9 @@ protected function validate($args)
throw new InvalidConditionException($validator->messages()->first()); throw new InvalidConditionException($validator->messages()->first());
} }
} }
public function toArray(): array
{
return $this->args;
}
} }
+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 <?php
declare(strict_types=1);
namespace Wearepixel\Cart; namespace Wearepixel\Cart;
use Illuminate\Support\ServiceProvider; 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 class CartServiceProvider extends ServiceProvider
{ {
/** public function boot(): void
* Indicates if loading of the provider is deferred.
*
* @var bool
*/
protected $defer = false;
/**
* Boot the service provider.
*/
public function boot()
{ {
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->publishes([ $this->publishes([
__DIR__ . '/../config/config.php' => config_path('cart.php'), __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,
]);
} }
} }
/** public function register(): void
* Register the service provider.
*
* @return void
*/
public function register()
{ {
$this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'cart'); $this->mergeConfigFrom(__DIR__ . '/../config/config.php', 'cart');
$this->app->singleton('cart.manager', fn($app) => new CartManager($app));
$this->app->singleton('cart', function ($app) { $this->app->singleton('cart', function ($app) {
$storageClass = config('cart.storage'); $config = config('cart');
$eventsClass = config('cart.events'); $events = $app['events'];
$driver = $app['cart.manager']->driver();
$storage = $storageClass ? new $storageClass : $app['session']; return new Cart($driver, $events, 'cart', $config);
$events = $eventsClass ? new $eventsClass : $app['events'];
$instanceName = 'cart';
// default session or cart identifier. This will be overridden when calling Cart::session($sessionKey)->add() etc..
// like when adding a cart for a specific user name. Session Key can be string or maybe a unique identifier to bind a cart
// to a specific user, this can also be a user ID
$session_key = '4yTlTDKu3oJOfzD';
return new Cart(
$storage,
$events,
$instanceName,
$session_key,
config('cart')
);
}); });
} }
/** public function provides(): array
* Get the services provided by the provider.
*
* @return array
*/
public function provides()
{ {
return []; 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 with <comment>Cart::extend()</comment> in a service provider's boot() method, then set 'driver' to its name in config/cart.php.");
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 {}
+1 -3
View File
@@ -2,6 +2,4 @@
namespace Wearepixel\Cart\Exceptions; namespace Wearepixel\Cart\Exceptions;
class UnknownModelException extends \Exception class UnknownModelException extends \Exception {}
{
}
+59 -1
View File
@@ -3,10 +3,68 @@
namespace Wearepixel\Cart\Facades; namespace Wearepixel\Cart\Facades;
use Illuminate\Support\Facades\Facade; 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 class CartFacade extends Facade
{ {
protected static function getFacadeAccessor() protected static function getFacadeAccessor(): string
{ {
return 'cart'; return 'cart';
} }
+25 -15
View File
@@ -5,25 +5,30 @@
class Helpers class Helpers
{ {
/** /**
* normalize price * Normalize prices
*
* @return float
*/ */
public static function normalizePrice($price) public static function normalizePrice($price): ?float
{ {
return (is_string($price)) ? floatval($price) : $price; return (is_string($price)) ? floatval($price) : $price;
} }
/** /**
* check if array is multi dimensional array * Get the rounding mode
* This will only check the first element of the array if it is still an array
* to decide that it is a multi dimensional, if you want to check the array strictly
* with all on its element, flag the second argument as true
*
* @param bool $recursive
* @return bool
*/ */
public static function isMultiArray($array, $recursive = false) public static function roundMode($config): int
{
if (isset($config['round_mode']) && $config['round_mode'] == 'up') {
return PHP_ROUND_HALF_UP;
}
return PHP_ROUND_HALF_DOWN;
}
/**
* Check if array is multi dimensional array
* This will only check if the first element of the array is an array
*/
public static function isMultiArray(array $array, bool $recursive = false): bool
{ {
if ($recursive) { if ($recursive) {
return (count($array) == count($array, COUNT_RECURSIVE)) ? false : true; return (count($array) == count($array, COUNT_RECURSIVE)) ? false : true;
@@ -35,8 +40,9 @@ public static function isMultiArray($array, $recursive = false)
return false; return false;
} }
} }
} }
return false;
} }
/** /**
@@ -56,10 +62,14 @@ public static function issetAndHasValueOrAssignDefault(&$var, $default = false)
public static function formatValue($value, $formatNumber, $config) public static function formatValue($value, $formatNumber, $config)
{ {
if (! $value) {
return 0;
}
if ($formatNumber || $config['format_numbers']) { if ($formatNumber || $config['format_numbers']) {
return floatval(number_format($value, $config['decimals'], $config['dec_point'], $config['thousands_sep'])); return round($value, $config['decimals'], self::roundMode($config));
} else { } else {
return $value; return floatval($value);
} }
} }
} }
+69 -6
View File
@@ -5,6 +5,14 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Wearepixel\Cart\Helpers\Helpers; 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 class ItemCollection extends Collection
{ {
/** /**
@@ -46,9 +54,9 @@ public function __get($name)
/** /**
* return the associated model of an item * return the associated model of an item
* *
* @return bool * @return mixed
*/ */
protected function getAssociatedModel() protected function getAssociatedModel(): mixed
{ {
if (! $this->has('associatedModel')) { if (! $this->has('associatedModel')) {
return null; return null;
@@ -91,7 +99,37 @@ public function getConditions()
return []; 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; $processed = 0;
if ($this->hasConditions()) { if ($this->hasConditions()) {
if (is_array($this->conditions)) { if (is_array($this->getConditions())) {
foreach ($this->conditions as $condition) { foreach ($this->getConditions() as $condition) {
($processed > 0) ? $toBeCalculated = $newPrice : $toBeCalculated = $originalPrice; ($processed > 0) ? $toBeCalculated = $newPrice : $toBeCalculated = $originalPrice;
$newPrice = $condition->applyCondition($toBeCalculated); $newPrice = $condition->applyCondition($toBeCalculated);
$processed++; $processed++;
} }
} else { } else {
$newPrice = $this['conditions']->applyCondition($originalPrice); $newPrice = $this->getConditions()->applyCondition($originalPrice);
} }
return Helpers::formatValue($newPrice, $formatted, $this->config); return Helpers::formatValue($newPrice, $formatted, $this->config);
@@ -132,6 +170,31 @@ public function getPriceWithConditions($formatted = true)
*/ */
public function getPriceSumWithConditions($formatted = true) public function getPriceSumWithConditions($formatted = true)
{ {
$conditions = $this->hasConditions() ? $this->getConditions() : [];
$conditions = is_array($conditions) ? $conditions : [$conditions];
$hasLimit = collect($conditions)->some(fn ($c) => $c->getAppliesToQuantity() !== null);
if (! $hasLimit) {
return Helpers::formatValue($this->getPriceWithConditions(false) * $this->quantity, $formatted, $this->config); 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\FileLoader;
use Illuminate\Translation\Translator; use Illuminate\Translation\Translator;
/**
* @method static \Illuminate\Validation\Validator make(array $data, array $rules, array $messages = [], array $attributes = [])
*/
abstract class Validator abstract class Validator
{ {
protected static $factory; protected static $factory;
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'format_numbers' => true,
'decimals' => 2,
'dec_point' => '.',
'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',
],
],
];
+3 -3
View File
@@ -1,8 +1,8 @@
<?php <?php
return [ return [
'format_numbers' => false, 'format_numbers' => true,
'decimals' => 2, 'decimals' => 2,
'dec_point' => '.', 'round_mode' => 'up',
'thousands_sep' => ',', 'driver' => 'session',
]; ];
+1 -1
View File
@@ -4,5 +4,5 @@
'format_numbers' => false, 'format_numbers' => false,
'decimals' => 3, 'decimals' => 3,
'dec_point' => ',', '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; $this->session[$key] = $value;
} }
public function forget($key)
{
unset($this->session[$key]);
}
} }
+2 -66
View File
@@ -1,69 +1,5 @@
<?php <?php
/* use Tests\TestCase;
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/
// pest()->extend(Tests\TestCase::class)->in('Feature'); uses(TestCase::class)->in(__DIR__);
/*
|--------------------------------------------------------------------------
| 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);
});
/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/
function fillCart()
{
$items = [
[
'id' => 456,
'name' => 'Sample Item 1',
'price' => 67.99,
'quantity' => 1,
'attributes' => [],
],
[
'id' => 568,
'name' => 'Sample Item 2',
'price' => 69.25,
'quantity' => 1,
'attributes' => [],
],
[
'id' => 856,
'name' => 'Sample Item 3',
'price' => 50.25,
'quantity' => 1,
'attributes' => [],
],
];
$this->cart->add($items);
}
+44 -2
View File
@@ -1,10 +1,52 @@
<?php <?php
declare(strict_types=1);
namespace Tests; 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; 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();
});
}
} }
File diff suppressed because it is too large Load Diff
+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);
});
});
+235 -63
View File
@@ -1,6 +1,7 @@
<?php <?php
use Wearepixel\Cart\Cart; use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct; use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock; use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -9,10 +10,9 @@
$events->shouldReceive('dispatch'); $events->shouldReceive('dispatch');
$this->cart = new Cart( $this->cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'shopping', 'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
}); });
@@ -21,16 +21,26 @@
Mockery::close(); Mockery::close();
}); });
test('cart can add item', function () { describe('cart', function () {
$this->cart->add(455, 'Sample Item', 100.99, 2, []); 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->isEmpty())->toBeFalse('Cart should not be empty');
expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1'); expect($this->cart->getContent()->count())->toEqual(1, 'Cart content should be 1');
expect($this->cart->getContent()->first()['id'])->toEqual(455, 'Item added has ID of 455 so first content ID should be 455'); 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($this->cart->getContent()->first()['price'])->toEqual(100.99, 'Item added has price of 100.99 so first content price should be 100.99');
}); });
test('cart can add item with no price', function () { test('can add an item in cents', function () {
$this->cart->add(1, 'Sample Item', 10099, 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(10099, 'Item added has price of 100.99 so first content price should be 100.99');
});
test('can add an item with no price', function () {
$this->cart->add(455, 'Sample Item', 0, 2, []); $this->cart->add(455, 'Sample Item', 0, 2, []);
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty'); expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
@@ -39,7 +49,35 @@
expect($this->cart->getTotal())->toEqual(0, 'Total should be 0'); expect($this->cart->getTotal())->toEqual(0, 'Total should be 0');
}); });
test('cart can add items as array', function () { test('can add an item an array with no price', function () {
$this->cart->add([
'id' => 455,
'name' => 'Sample Item',
'quantity' => 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(455, 'Item added has ID of 455 so first content ID should be 455');
expect($this->cart->getTotal())->toEqual(0, 'Total should be 0');
});
test('can add an item without attributes', function () {
$item = [
'id' => 1,
'name' => 'Pocket Shorts',
'price' => 67.99,
'quantity' => 2,
];
$this->cart->add($item);
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->getTotal())->toEqual(135.98, 'Cart total should be 135.98');
});
test('can add items as array', function () {
$item = [ $item = [
'id' => 456, 'id' => 456,
'name' => 'Sample Item', 'name' => 'Sample Item',
@@ -56,27 +94,27 @@
expect($this->cart->getContent()->first()['name'])->toEqual('Sample Item', 'The first content must have name of "Sample Item"'); expect($this->cart->getContent()->first()['name'])->toEqual('Sample Item', 'The first content must have name of "Sample Item"');
}); });
test('cart can add items with multidimensional array', function () { test('can add multiple items with an array', function () {
$items = [ $items = [
[ [
'id' => 456, 'id' => 1,
'name' => 'Sample Item 1', 'name' => 'Half Button Open Collar',
'price' => 67.99, 'price' => 71.99,
'quantity' => 4, 'quantity' => 1,
'attributes' => [], 'attributes' => [],
], ],
[ [
'id' => 568, 'id' => 2,
'name' => 'Sample Item 2', 'name' => 'Oli Crew Neck',
'price' => 69.25, 'price' => 59.99,
'quantity' => 4, 'quantity' => 1,
'attributes' => [], 'attributes' => [],
], ],
[ [
'id' => 856, 'id' => 3,
'name' => 'Sample Item 3', 'name' => 'Denim Shorts',
'price' => 50.25, 'price' => 69.99,
'quantity' => 4, 'quantity' => 3,
'attributes' => [], 'attributes' => [],
], ],
]; ];
@@ -85,25 +123,20 @@
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty'); expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
expect($this->cart->getContent()->toArray())->toHaveCount(3, 'Cart should have 3 items'); expect($this->cart->getContent()->toArray())->toHaveCount(3, 'Cart should have 3 items');
expect($this->cart->getTotal())->toEqual(341.95, 'Cart should have a total of 341.95');
}); });
test('cart can add item without attributes', function () { test('cart item prices should be normalized when added to cart', function () {
$item = [ // add a price in a string format should be converted to float
'id' => 456, $this->cart->add(1, 'Book of Languages Item', '100.99', 2, []);
'name' => 'Sample Item 1',
'price' => 67.99,
'quantity' => 4,
];
$this->cart->add($item); expect($this->cart->getContent()->first()['price'])->toBeFloat('Cart price should be a float');
expect($this->cart->isEmpty())->toBeFalse('Cart should not be empty');
}); });
test('cart update with attribute then attributes should be still instance of item attribute collection', function () { test('can update a cart item with new attributes and it should be still instance of item attribute collection', function () {
$item = [ $item = [
'id' => 456, 'id' => 1,
'name' => 'Sample Item 1', 'name' => 'Leather Shows',
'price' => 67.99, 'price' => 67.99,
'quantity' => 4, 'quantity' => 4,
'attributes' => [ 'attributes' => [
@@ -111,13 +144,12 @@
'color' => 'red', 'color' => 'red',
], ],
]; ];
$this->cart->add($item); $this->cart->add($item);
// lets get the attribute and prove first its an instance of $item = $this->cart->get(1);
// ItemAttributeCollection
$item = $this->cart->get(456);
expect($item->attributes)->toBeInstanceOf('Wearepixel\Cart\ItemAttributeCollection'); expect($item->attributes)->toBeInstanceOf('Wearepixel\\Cart\\ItemAttributeCollection');
// now lets update the item with its new attributes // now lets update the item with its new attributes
// when we get that item from cart, it should still be an instance of ItemAttributeCollection // when we get that item from cart, it should still be an instance of ItemAttributeCollection
@@ -127,9 +159,161 @@
'color' => 'red', 'color' => 'red',
], ],
]; ];
$this->cart->update(456, $updatedItem);
expect($item->attributes)->toBeInstanceOf('Wearepixel\Cart\ItemAttributeCollection'); $this->cart->update(1, $updatedItem);
expect($item->attributes)->toBeInstanceOf('Wearepixel\\Cart\\ItemAttributeCollection');
});
test('item orders are preserved by the order they are added', function () {
$items = [
[
'id' => 50,
'name' => 'Leather Shows',
'price' => 67.99,
'quantity' => 4,
'attributes' => [
'product_id' => '145',
'color' => 'red',
],
],
[
'id' => 2,
'name' => 'Oli Crew Neck',
'price' => 59.99,
'quantity' => 1,
'attributes' => [],
],
];
$this->cart->add($items);
$items = $this->cart->getContent();
expect($items->toArray())->toHaveCount(2, 'Cart should have 2 items');
expect($items->first()->id)->toEqual(50, 'First item should have ID of 1');
expect($items->last()->id)->toEqual(2, 'Last item should have ID of 2');
});
test('item orders are preserved by the order they are added even when items are updated', function () {
$items = [
[
'id' => 50,
'name' => 'Leather Shows',
'price' => 67.99,
'quantity' => 4,
'attributes' => [
'product_id' => '145',
'color' => 'red',
],
],
[
'id' => 2,
'name' => 'Oli Crew Neck',
'price' => 59.99,
'quantity' => 1,
'attributes' => [],
],
];
$this->cart->add($items);
$this->cart->update(50, ['name' => 'Updated Name']);
$items = $this->cart->getContent();
expect($items->toArray())->toHaveCount(2, 'Cart should have 2 items');
expect($items->first()->id)->toEqual(50, 'First item should have ID of 1');
expect($items->last()->id)->toEqual(2, 'Last item should have ID of 2');
});
test('can update existing items', function () {
$items = [
[
'id' => 1,
'name' => 'Magic The Gathering Card Pack',
'price' => 12.99,
'quantity' => 3,
'attributes' => [],
],
[
'id' => 2,
'name' => 'Magic The Gathering Card Case',
'price' => 39.99,
'quantity' => 1,
'attributes' => [],
],
];
$this->cart->add($items);
$item = $this->cart->get(1);
expect($item['name'])->toEqual('Magic The Gathering Card Pack', 'Item name should be "Magic The Gathering Card Pack"');
expect($item['price'])->toEqual(12.99, 'Item price should be "12.99"');
expect($item['quantity'])->toEqual(3, 'Item quantity should be 3');
// when cart's item quantity is updated, the subtotal should be updated as well
$this->cart->update(1, [
'name' => 'Magic The Gathering Card Packs',
'quantity' => 2,
'price' => 10.20,
]);
$item = $this->cart->get(1);
expect($item['name'])->toEqual('Magic The Gathering Card Packs', 'Item name should be "Magic The Gathering Card Packs"');
expect($item['price'])->toEqual(10.20, 'Item price should be 10.20');
expect($item['quantity'])->toEqual(5, 'Item quantity should be 2');
});
test('can update existing item quanties with decimals', function () {
$items = [
'id' => 1,
'name' => 'Fertilizer',
'price' => 30,
'quantity' => 7.8,
'attributes' => [
'unit' => 'kilogram',
],
];
$this->cart->add($items);
$item = $this->cart->get(1);
expect($item['quantity'])->toEqual(7.8, 'Item quantity should be 7.8');
// when cart's item quantity is updated, the subtotal should be updated as well
$this->cart->update(1, [
'quantity' => [
'relative' => true,
'value' => 2.1,
],
]);
$item = $this->cart->get(1);
expect($item['quantity'])->toEqual(9.9, 'Item quantity should be 9.9');
});
test('should throw an exception when no quantity is provided', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->expectExceptionMessage('The quantity must be at least 0.1.');
$this->cart->add(455, 'Sample Item', 100.99, 0, []);
});
test('should throw an exception when no id is provided', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->expectExceptionMessage('The id field is required.');
$this->cart->add('', 'Sample Item', 100.99, 2, []);
});
test('should throw an exception when no name is provided', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->expectExceptionMessage('The name field is required.');
$this->cart->add(523, '', 100.99, 2, []);
});
}); });
test('cart items attributes', function () { test('cart items attributes', function () {
@@ -222,13 +406,6 @@
expect($item['quantity'])->toEqual(5, 'Item quantity should be 5'); expect($item['quantity'])->toEqual(5, 'Item quantity should be 5');
}); });
test('item price should be normalized when added to cart', function () {
// add a price in a string format should be converted to float
$this->cart->add(455, 'Sample Item', '100.99', 2, []);
expect($this->cart->getContent()->first()['price'])->toBeFloat('Cart price should be a float');
});
test('it removes an item on cart by item id', function () { test('it removes an item on cart by item id', function () {
$items = [ $items = [
[ [
@@ -331,8 +508,6 @@
}); });
test('sub total when item quantity is updated by reduced', function () { test('sub total when item quantity is updated by reduced', function () {
// $this->cart->setConfig(['format_numbers' => true, 'decimals' => 2, 'dec_point' => '.', 'thousands_sep' => ',']);
$items = [ $items = [
[ [
'id' => 456, 'id' => 456,
@@ -398,21 +573,6 @@
expect($item['quantity'])->toEqual(3, 'Item quantity of with item ID of 456 should now be reduced to 2'); expect($item['quantity'])->toEqual(3, 'Item quantity of with item ID of 456 should now be reduced to 2');
}); });
test('should throw exception when provided invalid values scenario one', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->cart->add(455, 'Sample Item', 100.99, 0, []);
});
test('should throw exception when provided invalid values scenario two', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->cart->add('', 'Sample Item', 100.99, 2, []);
});
test('should throw exception when provided invalid values scenario three', function () {
$this->expectException('Wearepixel\Cart\Exceptions\InvalidItemException');
$this->cart->add(523, '', 100.99, 2, []);
});
test('clearing cart', function () { test('clearing cart', function () {
$items = [ $items = [
[ [
@@ -529,3 +689,15 @@
expect($this->cart->getTotalQuantity())->toBeInt('Return type should be INT'); expect($this->cart->getTotalQuantity())->toBeInt('Return type should be INT');
expect($this->cart->getTotalQuantity())->toEqual(12, 'Cart\'s quantity should be 4.'); 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 <?php
use Wearepixel\Cart\Cart; use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock; use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () {}); beforeEach(function () {});
@@ -9,15 +10,14 @@
Mockery::close(); Mockery::close();
}); });
test('event cart created', function () { test('event cart Created', function () {
$events = Mockery::mock('Illuminate\Contracts\Events\Dispatcher'); $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( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
@@ -25,16 +25,15 @@
}); });
test('event cart adding', function () { test('event cart adding', function () {
$events = Mockery::mock('Illuminate\Events\Dispatcher'); $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);
$events->shouldReceive('dispatch')->once()->with('cart.adding', Mockery::type('array'), true); $events->shouldReceive('dispatch')->once()->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.added', Mockery::type('array'), true); $events->shouldReceive('dispatch')->once()->with('LaravelCart.Added', Mockery::type('array'), true);
$cart = new Cart( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
@@ -44,16 +43,15 @@
}); });
test('event cart adding multiple times', function () { test('event cart adding multiple times', function () {
$events = Mockery::mock('Illuminate\Events\Dispatcher'); $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);
$events->shouldReceive('dispatch')->times(2)->with('cart.adding', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(2)->with('cart.added', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(2)->with('LaravelCart.Added', Mockery::type('array'), true);
$cart = new Cart( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
@@ -64,10 +62,10 @@
}); });
test('event cart adding multiple times scenario two', function () { test('event cart adding multiple times scenario two', function () {
$events = Mockery::mock('Illuminate\Events\Dispatcher'); $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);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$items = [ $items = [
[ [
@@ -94,10 +92,9 @@
]; ];
$cart = new Cart( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
@@ -107,12 +104,12 @@
}); });
test('event cart remove item', function () { test('event cart remove item', function () {
$events = Mockery::mock('Illuminate\Events\Dispatcher'); $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);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('cart.removing', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(1)->with('LaravelCart.Removing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(1)->with('cart.removed', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(1)->with('LaravelCart.Removed', Mockery::type('array'), true);
$items = [ $items = [
[ [
@@ -139,10 +136,9 @@
]; ];
$cart = new Cart( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
@@ -154,12 +150,12 @@
}); });
test('event cart clear', function () { test('event cart clear', function () {
$events = Mockery::mock('Illuminate\Events\Dispatcher'); $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);
$events->shouldReceive('dispatch')->times(3)->with('cart.adding', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Adding', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->times(3)->with('cart.added', Mockery::type('array'), true); $events->shouldReceive('dispatch')->times(3)->with('LaravelCart.Added', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.clearing', Mockery::type('array'), true); $events->shouldReceive('dispatch')->once()->with('LaravelCart.Clearing', Mockery::type('array'), true);
$events->shouldReceive('dispatch')->once()->with('cart.cleared', Mockery::type('array'), true); $events->shouldReceive('dispatch')->once()->with('LaravelCart.Cleared', Mockery::type('array'), true);
$items = [ $items = [
[ [
@@ -186,10 +182,9 @@
]; ];
$cart = new Cart( $cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'cart', 'cart',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
+3 -4
View File
@@ -1,6 +1,7 @@
<?php <?php
use Wearepixel\Cart\Cart; use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock; use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () { beforeEach(function () {
@@ -8,18 +9,16 @@
$events->shouldReceive('dispatch'); $events->shouldReceive('dispatch');
$this->cart1 = new Cart( $this->cart1 = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'uniquesessionkey123'),
$events, $events,
'shopping', 'shopping',
'uniquesessionkey123',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
$this->cart2 = new Cart( $this->cart2 = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'uniquesessionkey456'),
$events, $events,
'wishlist', 'wishlist',
'uniquesessionkey456',
require (__DIR__ . '/../Helpers/ConfigMock.php') 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\Cart;
use Wearepixel\Cart\CartCondition; use Wearepixel\Cart\CartCondition;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\MockProduct; use Wearepixel\Cart\Tests\Helpers\MockProduct;
use Wearepixel\Cart\Tests\Helpers\SessionMock; use Wearepixel\Cart\Tests\Helpers\SessionMock;
@@ -10,10 +11,9 @@
$events->shouldReceive('dispatch'); $events->shouldReceive('dispatch');
$this->cart = new Cart( $this->cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'shopping', 'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMock.php') require (__DIR__ . '/../Helpers/ConfigMock.php')
); );
}); });
+2 -2
View File
@@ -1,6 +1,7 @@
<?php <?php
use Wearepixel\Cart\Cart; use Wearepixel\Cart\Cart;
use Wearepixel\Cart\Drivers\SessionDriver;
use Wearepixel\Cart\Tests\Helpers\SessionMock; use Wearepixel\Cart\Tests\Helpers\SessionMock;
beforeEach(function () { beforeEach(function () {
@@ -8,10 +9,9 @@
$events->shouldReceive('dispatch'); $events->shouldReceive('dispatch');
$this->cart = new Cart( $this->cart = new Cart(
new SessionMock, new SessionDriver(new SessionMock, 'SAMPLESESSIONKEY'),
$events, $events,
'shopping', 'shopping',
'SAMPLESESSIONKEY',
require (__DIR__ . '/../Helpers/ConfigMockOtherFormat.php') 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);