commit 48df5db468be16f3f05f029ee792ee241e278462 Author: Brandon Shipley Date: Sat Oct 11 18:31:07 2025 -0700 first commit splitting onto its own repo diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..85f43aa --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,143 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + testsuite: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php-version: ['8.2', '8.4'] + db-type: ['mysql'] + # db-type: ['sqlite', 'mysql', 'pgsql'] + prefer-lowest: [''] + include: + - php-version: '8.2' + db-type: 'sqlite' + prefer-lowest: 'prefer-lowest' + + services: + mysql8: + image: mysql:8.0 + env: + MYSQL_ALLOW_EMPTY_PASSWORD: yes + MYSQL_DATABASE: cakephp +# services: +# postgres: +# image: postgres +# ports: +# - 5432:5432 +# env: +# POSTGRES_PASSWORD: postgres +# mysql8: +# image: mysql:8.0 +# env: +# MYSQL_ALLOW_EMPTY_PASSWORD: yes +# MYSQL_DATABASE: test_db +# ports: +# - 3306:3306 + + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, sqlite, pdo_${{ matrix.db-type }} + coverage: pcov + + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: Composer install + run: | + composer --version + if ${{ matrix.prefer-lowest == 'prefer-lowest' }} + then + composer update --prefer-lowest --prefer-stable + composer require --dev dereuromark/composer-prefer-lowest:dev-master + else + composer install --no-progress --prefer-dist --optimize-autoloader + fi + + - name: Setup problem matchers for PHPUnit + if: matrix.db-type == 'mysql' + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + + - name: Run PHPUnit tests + env: + TEST_MYSQL_HOST: mysql8 + TEST_MYSQL_CHARSET: utf8mb4 + TEST_MYSQL_DBNAME: cakephp + TEST_MYSQL_USERNAME: root + TEST_MYSQL_PASSWORD: + run: | + if [[ ${{ matrix.php-version }} == '8.2' ]]; then + vendor/bin/phpunit --coverage-clover=coverage.xml + else + vendor/bin/phpunit + fi + + - name: Validate prefer-lowest + if: matrix.prefer-lowest == 'prefer-lowest' + run: vendor/bin/validate-prefer-lowest -m + +# - name: Upload coverage reports to Codecov +# if: success() && matrix.php-version == '8.2' +# uses: codecov/codecov-action@v4 +# with: +# token: ${{ secrets.CODECOV_TOKEN }} + + validation: + name: Coding Standard & Static Analysis + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, intl, sqlite + coverage: none + + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: Composer install + run: | + composer --version + if ${{ matrix.prefer-lowest == 'prefer-lowest' }} + then + composer update --prefer-lowest --prefer-stable + composer require --dev dereuromark/composer-prefer-lowest:dev-master + else + composer install --no-progress --prefer-dist --optimize-autoloader + fi + + - name: Composer phpstan setup + run: composer stan-setup + + - name: Run phpstan + run: vendor/bin/phpstan analyse --error-format=github + + - name: Run phpcs + run: composer cs-check \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..589b4c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + testsuite: + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + php-version: ['8.1', '8.4'] + db-type: ['sqlite', 'mysql', 'pgsql'] + prefer-lowest: [''] + include: + - php-version: '8.1' + db-type: 'sqlite' + prefer-lowest: 'prefer-lowest' + + services: + postgres: + image: postgres + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: postgres + + steps: + - uses: actions/checkout@v4 + + - name: Setup Service + if: matrix.db-type == 'mysql' + run: | + sudo service mysql start + mysql -h 127.0.0.1 -u root -proot -e 'CREATE DATABASE cakephp;' + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, pdo_${{ matrix.db-type }} + coverage: pcov + + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: Composer install + run: | + composer --version + if ${{ matrix.prefer-lowest == 'prefer-lowest' }} + then + composer update --prefer-lowest --prefer-stable + composer require --dev dereuromark/composer-prefer-lowest:dev-master + else + composer install --no-progress --prefer-dist --optimize-autoloader + fi + + - name: Setup problem matchers for PHPUnit + if: matrix.db-type == 'mysql' + run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" + + - name: Wait for MySQL + if: matrix.db-type == 'mysql' + run: while ! `mysqladmin ping -h 127.0.0.1 --silent`; do printf 'Waiting for MySQL...\n'; sleep 2; done; + + - name: Run PHPUnit + run: | + if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi + if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp?encoding=utf8'; fi + if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi + if [[ ${{ matrix.php-version }} == '8.1' ]]; then + vendor/bin/phpunit --coverage-clover=coverage.xml + else + vendor/bin/phpunit + fi + + - name: Validate prefer-lowest + if: matrix.prefer-lowest == 'prefer-lowest' + run: vendor/bin/validate-prefer-lowest -m + +# - name: Upload coverage reports to Codecov +# if: success() && matrix.php-version == '8.1' +# uses: codecov/codecov-action@v4 +# with: +# token: ${{ secrets.CODECOV_TOKEN }} + + validation: + name: Coding Standard & Static Analysis + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + extensions: mbstring, intl + coverage: none + + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + + - name: Composer phpstan setup + run: composer stan-setup + + - name: Run phpstan + run: vendor/bin/phpstan analyse --error-format=github + + - name: Run phpcs + run: composer cs-check \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c40431b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/composer.lock +/composer.phar +/phpunit.xml +/.phpunit.result.cache +/phpunit.phar +/config/Migrations/schema-dump-default.lock +/vendor/ +/.idea/ +tmp diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..2ffd7d9 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e2f3d2c --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 HI POWERED DEV, LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..773fdd3 --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "hi-powered-dev/cake-carts", + "description": "A CakePHP plugin for storing entities in carts", + "type": "cakephp-plugin", + "license": "MIT", + "minimum-stability": "dev", + "require": { + "php": ">=8.2", + "dereuromark/cakephp-tools": "^3.9", + "muffin/trash": "^4.2", + "hi-powered-dev/cheese-cake": "dev-prod", + "cakephp/cakephp": "^5.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.1", + "cakephp/migrations": "^4.0.0" + }, + "autoload": { + "psr-4": { + "CakeCarts\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CakeCarts\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/config/Migrations/20251008093507_CreateCarts.php b/config/Migrations/20251008093507_CreateCarts.php new file mode 100644 index 0000000..de2aa5e --- /dev/null +++ b/config/Migrations/20251008093507_CreateCarts.php @@ -0,0 +1,114 @@ +table('carts', ['id' => false, 'primary_key' => ['id']]); + $table->addColumn('id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('cart_type_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->addColumn('session_id', 'string', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('user_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]); + $table->addColumn('user_id_uuid', 'uuid', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('created', 'datetime', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('modified', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('deleted', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('removed', 'datetime', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('removed_reason_id', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]); + $table->addColumn('num_items', 'integer', [ + 'default' => 0, + 'null' => false, + ]); + $table->create(); + + $table = $this->table('cart_items', ['id' => false, 'primary_key' => ['id']]); + $table->addColumn('id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('foreign_key', 'integer', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('foreign_key_uuid', 'uuid', [ + 'default' => null, + 'null' => true, + ]); + $table->addColumn('model', 'string', [ + 'default' => null, + 'limit' => 255, + 'null' => false, + ]); + $table->addColumn('cart_id', 'uuid', [ + 'default' => null, + 'null' => false, + ]); + $table->addColumn('position', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => true, + ]); + $table->addColumn('qty', 'integer', [ + 'default' => null, + 'limit' => 11, + 'null' => false, + ]); + $table->addColumn('price', 'decimal', [ + 'default' => null, + 'null' => true, + 'precision' => 13, + 'scale' => 5, + ]); + $table->addColumn('subtotal', 'decimal', [ + 'default' => null, + 'null' => true, + 'precision' => 13, + 'scale' => 5, + ]); + + $table->create(); + } +} diff --git a/config/Migrations/schema-dump-default.lock b/config/Migrations/schema-dump-default.lock new file mode 100644 index 0000000..1f99461 Binary files /dev/null and b/config/Migrations/schema-dump-default.lock differ diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..35ea757 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/src/CakeCartsPlugin.php b/src/CakeCartsPlugin.php new file mode 100644 index 0000000..737439d --- /dev/null +++ b/src/CakeCartsPlugin.php @@ -0,0 +1,99 @@ +plugin( + 'CakeCarts', + ['path' => '/cake-carts'], + function (RouteBuilder $builder) { + // Add custom routes here + $builder->connect('/wishlist', ['controller' => 'CakeCarts', 'action' => 'wishlist']); + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + // remove this method hook if you don't need it + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + // remove this method hook if you don't need it + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + // remove this method hook if you don't need it + } +} diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php new file mode 100644 index 0000000..a8a0743 --- /dev/null +++ b/src/Controller/AppController.php @@ -0,0 +1,10 @@ +loadComponent('CakeCarts.ShoppingCart', [ + // This is default config. You can modify "actions" as needed to make + // component work only for specified methods. + 'actions' => ['add'], + ]); + } + + /** + * Add method + * + * @return \Cake\Http\Response|null|void Redirects on successful add, renders view otherwise. + */ + public function add() + { + $this->request->allowMethod(['post', 'put', 'patch']); + $this->Authorization->skipAuthorization(); + + Log::debug(print_r('$this->request->getData()', true)); + Log::debug(print_r($this->request->getData(), true)); + $cart = $this->viewBuilder()->getVar('cart'); + $postData = $this->request->getData(); + $postData['cart_id'] = $cart->id; + // get product skus with variants + $price = $this->request->getData('price'); + $qty = $this->request->getData('qty', 1); + $postData['price'] = $price; + $postData['subtotal'] = isset($price) ? bcmul("$price", "$qty", 5) : null; + + $newCartItem = $this->Carts->CartItems->newEntity($postData, [ + 'validate' => Configure::readOrFail('CakeCarts.CartItems.requirePricing') ? 'requirePricing' : 'default', + ]); + + if ($this->Carts->CartItems->save($newCartItem)) { + $this->Flash->success('Added to cart'); + + return $this->redirect($this->referer([ + 'plugin' => 'CakeCarts', + 'controller' => 'CartItems', + 'action' => 'index' + ])); + } + Log::debug(print_r('$newCartItem->getErrors()', true)); + Log::debug(print_r($newCartItem->getErrors(), true)); + + $this->Flash->error('Failed to add to cart.'); + + return $this->redirect($this->referer([ + 'plugin' => 'CakeCarts', + 'controller' => 'CartItems', + 'action' => 'index' + ])); + } + + /** + * Edit method + * + * @param string|null $id Cart Item id. + * @return \Cake\Http\Response|null|void Redirects on successful edit, renders view otherwise. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function edit($id = null) + { + $this->request->allowMethod(['post', 'put', 'patch']); + + $cartItem = $this->CartItems->find() + ->where(['CartItems.id' => $id]) + ->contain(['Carts']) + ->firstOrFail(); + + $this->ShoppingCart->checkIfIsOwnCart($cartItem->cart); + $this->Authorization->skipAuthorization(); + + if ($this->request->is(['patch', 'post', 'put'])) { + $postData = $this->request->getData(); + $qty = $this->request->getData('qty', 1); + $price = $this->request->getData('price', null); + $subtotal = isset($price) ? bcmul("$qty", "$price", 5) : null; + $postData['subtotal'] = $subtotal; + $cartItem = $this->CartItems->patchEntity($cartItem, $postData, [ + 'validate' => Configure::readOrFail('CakeCarts.CartItems.requirePricing') ? 'requiresPricing' : 'default', + ]); + if ($this->CartItems->save($cartItem)) { + $this->Flash->success(__('The cart item has been saved.')); + + return $this->redirect($this->referer([ + 'plugin' => 'CakeCarts', + 'controller' => 'CartItems', + 'action' => 'index' + ])); + } + Log::debug(print_r('$cartItem->getErrors()', true)); + Log::debug(print_r($cartItem->getErrors(), true)); + $this->Flash->error(__('The cart item could not be saved. Please, try again.')); + } + + return $this->redirect($this->referer([ + 'plugin' => 'CakeCarts', + 'controller' => 'CartItems', + 'action' => 'index' + ])); + } + + /** + * Delete method + * + * @param string|null $id Cart Item id. + * @return \Cake\Http\Response|null Redirects to index. + * @throws \Cake\Datasource\Exception\RecordNotFoundException When record not found. + */ + public function delete($id = null) + { + $this->request->allowMethod(['post', 'delete']); + $identity = $this->getRequest()->getAttribute('identity'); + +// $cart = $this->viewBuilder()->getVar('cart'); + $cartItem = $this->CartItems->find() + ->where(['CartItems.id' => $id]) + ->contain(['Carts']) + ->firstOrFail(); + + $this->ShoppingCart->checkIfIsOwnCart($cartItem->cart); + $this->Authorization->skipAuthorization(); + + unset($cartItem->cart); + if ($this->CartItems->delete($cartItem)) { + $this->Flash->success(__('The cart item has been deleted.')); + } else { + $this->Flash->error(__('The cart item could not be deleted. Please, try again.')); + } + + return $this->redirect($this->referer([ + 'plugin' => 'CakeCarts', + 'controller' => 'CartItems', + 'action' => 'index' + ])); + } +} diff --git a/src/Controller/CartsController.php b/src/Controller/CartsController.php new file mode 100644 index 0000000..d0de855 --- /dev/null +++ b/src/Controller/CartsController.php @@ -0,0 +1,38 @@ +loadComponent('CakeCarts.ShoppingCart', [ + // This is default config. You can modify "actions" as needed to make + // component work only for specified methods. + 'actions' => true, + ]); + } + + /** + * Index method + * + * @return \Cake\Http\Response|null|void Renders view + */ + public function index() + { + // use cart from beforeFilter + } +} diff --git a/src/Controller/Component/ShoppingCartComponent.php b/src/Controller/Component/ShoppingCartComponent.php new file mode 100644 index 0000000..87997a3 --- /dev/null +++ b/src/Controller/Component/ShoppingCartComponent.php @@ -0,0 +1,138 @@ +userIdField = Configure::readOrFail('CakeCarts.Users.user_id') === 'uuid' ? 'user_id_uuid' : 'user_id'; + } + + /** + * Default configuration. + * + * @var array + */ + protected array $_defaultConfig = []; + + public function beforeFilter(EventInterface $event): void + { + if (!$this->_isActionEnabled()) { + return; + } + + $sessionId = $this->getSessionId(); + $cart = $this->findExistingCartOrCreate($sessionId); + + $this->getController()->set(compact('cart')); + } + + public function findExistingCartOrCreate(string $sessionId, int $cartTypeId = null) + { + $cartsTable = TableRegistry::getTableLocator()->get(Configure::readOrFail('CakeCarts.Carts.table')); + $userIdField = Configure::readOrFail('CakeCarts.Users.user_id') === 'integer' ? 'user_id' : 'user_id_uuid'; + $identity = $this->getController()->getRequest()->getAttribute('identity'); + + $cartTypeId = $cartTypeId ?? CartTypeId::Cart->value; + + $cart = $cartsTable + ->findBySessionId($sessionId) + ->contain(['CartItems']) + ->where(['cart_type_id' => $cartTypeId]) + ->first(); + + if (isset($cart) && isset($identity) && !isset($cart[$this->userIdField])) { + $cart = $cartsTable->patchEntity([ + $this->userIdField => $identity->getIdentifier(), + ]); + + $cart = $cartsTable->saveOrFail($cart); + } + if (!isset($cart)) { + $cart = $cartsTable->newEntity([ + 'cart_type_id' => $cartTypeId, + 'session_id' => $sessionId, + $this->userIdField => isset($identity) ? $identity->getIdentifier() : null, + 'num_items' => 0, + 'cart_items' => [], + ]); + + $cart = $cartsTable->saveOrFail($cart); + } + + return $cart; + } + + public function getUserIdField() + { + return $this->userIdField; + } + + /** + * @return string + */ + public function getSessionId(): string + { + if (!$this->getController()->getRequest()->getSession()->started()) { + $this->getController()->getRequest()->getSession()->start(); + } + + if (!$this->getController()->getRequest()->getSession()->check('CakeCarts.session_id')) { + $this->getController()->getRequest()->getSession()->write('CakeCarts.session_id', $this->getController()->getRequest()->getSession()->id()); + } + + return $this->getController()->getRequest()->getSession()->read('CakeCarts.session_id'); + } + + /** + * @param EntityInterface $cart + * @throws RecordNotFoundException + * + * @return void + */ + public function checkIfIsOwnCart(EntityInterface $cart): void + { + $identity = $this->getController()->getRequest()->getAttribute('identity'); + + if (!isset($identity) && isset($cart->session_id) && ($cart->session_id != $this->getSessionId())) { + throw new RecordNotFoundException(); + } + if (isset($identity) && $identity->getIdentifier() != $cart->get($this->getUserIdField())) { + throw new RecordNotFoundException(); + } + } + + /** + * @return bool + */ + protected function _isActionEnabled(): bool + { + $actions = $this->getConfig('actions'); + if (is_bool($actions)) { + return $actions; + } + + return in_array($this->getController()->getRequest()->getParam('action'), (array)$actions, true); + } +} diff --git a/src/Model/Entity/Cart.php b/src/Model/Entity/Cart.php new file mode 100644 index 0000000..fa6ff0e --- /dev/null +++ b/src/Model/Entity/Cart.php @@ -0,0 +1,50 @@ + + */ + protected array $_accessible = [ + 'cart_type_id' => true, + 'session_id' => true, + 'user_id' => true, + 'user_id_uuid' => true, + 'created' => true, + 'modified' => true, + 'deleted' => true, + 'removed' => true, + 'removed_reason_id' => true, + 'num_items' => true, + 'user' => true, + 'cart_items' => true, + ]; +} diff --git a/src/Model/Entity/CartItem.php b/src/Model/Entity/CartItem.php new file mode 100644 index 0000000..f3de988 --- /dev/null +++ b/src/Model/Entity/CartItem.php @@ -0,0 +1,45 @@ + + */ + protected array $_accessible = [ + 'foreign_key' => true, + 'foreign_key_uuid' => true, + 'model' => true, + 'cart_id' => true, + 'position' => true, + 'qty' => true, + 'price' => true, + 'subtotal' => true, + 'cart' => true, + ]; +} diff --git a/src/Model/Enum/CartTypeId.php b/src/Model/Enum/CartTypeId.php new file mode 100644 index 0000000..125def1 --- /dev/null +++ b/src/Model/Enum/CartTypeId.php @@ -0,0 +1,23 @@ + 'Cart', + self::Wishlist => 'Wishlist', + self::CustomList => 'CustomList' + }; + } +} diff --git a/src/Model/Table/CartItemsTable.php b/src/Model/Table/CartItemsTable.php new file mode 100644 index 0000000..85ea94a --- /dev/null +++ b/src/Model/Table/CartItemsTable.php @@ -0,0 +1,112 @@ + newEntities(array $data, array $options = []) + * @method \CakeCarts\Model\Entity\CartItem get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \CakeCarts\Model\Entity\CartItem findOrCreate($search, ?callable $callback = null, array $options = []) + * @method \CakeCarts\Model\Entity\CartItem patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method array<\CakeCarts\Model\Entity\CartItem> patchEntities(iterable $entities, array $data, array $options = []) + * @method \CakeCarts\Model\Entity\CartItem|false save(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method \CakeCarts\Model\Entity\CartItem saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem>|false saveMany(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem> saveManyOrFail(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem>|false deleteMany(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\CartItem>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\CartItem> deleteManyOrFail(iterable $entities, array $options = []) + */ +class CartItemsTable extends Table +{ + /** + * Initialize method + * + * @param array $config The configuration for the Table. + * @return void + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('cart_items'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->belongsTo('Carts', [ + 'foreignKey' => 'cart_id', + 'joinType' => 'INNER', + 'className' => 'CakeCarts.Carts', + ]); + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->uuid('product_sku_id') + ->requirePresence('product_sku_id', 'create') + ->notEmptyString('product_sku_id'); + + $validator + ->uuid('cart_id') + ->notEmptyString('cart_id'); + + $validator + ->integer('position') + ->allowEmptyString('position'); + + $validator + ->integer('qty') + ->requirePresence('qty', 'create') + ->notEmptyString('qty'); + + return $validator; + } + + public function validationRequiresPricing(Validator $validator): Validator + { + $validator = $this->validationDefault($validator); + + $validator + ->decimal('price') + ->requirePresence('price', 'create') + ->allowEmptyString('price'); + + $validator + ->decimal('subtotal') + ->requirePresence('subtotal', 'create') + ->notEmptyString('subtotal'); + + return $validator; + } + + /** + * Returns a rules checker object that will be used for validating + * application integrity. + * + * @param \Cake\ORM\RulesChecker $rules The rules object to be modified. + * @return \Cake\ORM\RulesChecker + */ + public function buildRules(RulesChecker $rules): RulesChecker + { + $rules->add($rules->existsIn(['cart_id'], 'Carts'), ['errorField' => 'cart_id']); + + return $rules; + } +} diff --git a/src/Model/Table/CartsTable.php b/src/Model/Table/CartsTable.php new file mode 100644 index 0000000..6acdcc2 --- /dev/null +++ b/src/Model/Table/CartsTable.php @@ -0,0 +1,105 @@ + newEntities(array $data, array $options = []) + * @method \CakeCarts\Model\Entity\Cart get(mixed $primaryKey, array|string $finder = 'all', \Psr\SimpleCache\CacheInterface|string|null $cache = null, \Closure|string|null $cacheKey = null, mixed ...$args) + * @method \CakeCarts\Model\Entity\Cart findOrCreate($search, ?callable $callback = null, array $options = []) + * @method \CakeCarts\Model\Entity\Cart patchEntity(\Cake\Datasource\EntityInterface $entity, array $data, array $options = []) + * @method array<\CakeCarts\Model\Entity\Cart> patchEntities(iterable $entities, array $data, array $options = []) + * @method \CakeCarts\Model\Entity\Cart|false save(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method \CakeCarts\Model\Entity\Cart saveOrFail(\Cake\Datasource\EntityInterface $entity, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart>|false saveMany(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart> saveManyOrFail(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart>|false deleteMany(iterable $entities, array $options = []) + * @method iterable<\CakeCarts\Model\Entity\Cart>|\Cake\Datasource\ResultSetInterface<\CakeCarts\Model\Entity\Cart> deleteManyOrFail(iterable $entities, array $options = []) + * + * @mixin \Cake\ORM\Behavior\TimestampBehavior + */ +class CartsTable extends Table +{ + /** + * Initialize method + * + * @param array $config The configuration for the Table. + * @return void + */ + public function initialize(array $config): void + { + parent::initialize($config); + + $this->setTable('carts'); + $this->setDisplayField('id'); + $this->setPrimaryKey('id'); + + $this->addBehavior('Timestamp'); + + $this->hasMany('CartItems', [ + 'foreignKey' => 'cart_id', + 'className' => 'CakeCarts.CartItems', + ]); + $this->getSchema()->setColumnType('cart_type_id', EnumType::from(CartTypeId::class)); + + } + + /** + * Default validation rules. + * + * @param \Cake\Validation\Validator $validator Validator instance. + * @return \Cake\Validation\Validator + */ + public function validationDefault(Validator $validator): Validator + { + $validator + ->integer('cart_type_id') + ->requirePresence('cart_type_id', 'create') + ->notEmptyString('cart_type_id'); + + $validator + ->scalar('session_id') + ->maxLength('session_id', 255) + ->allowEmptyString('session_id'); + + $validator + ->uuid('user_id_uuid') + ->allowEmptyString('user_id_uuid'); + + $validator + ->integer('user_id') + ->allowEmptyString('user_id'); + + $validator + ->dateTime('deleted') + ->allowEmptyDateTime('deleted'); + + $validator + ->dateTime('removed') + ->allowEmptyDateTime('removed'); + + $validator + ->integer('removed_reason_id') + ->allowEmptyString('removed_reason_id'); + + $validator + ->integer('num_items') + ->notEmptyString('num_items'); + + return $validator; + } +} diff --git a/tests/Fixture/CartItemsFixture.php b/tests/Fixture/CartItemsFixture.php new file mode 100644 index 0000000..a0a7bea --- /dev/null +++ b/tests/Fixture/CartItemsFixture.php @@ -0,0 +1,35 @@ +records = [ + [ + 'id' => '79f66e8d-8d8d-4095-adc4-fd15234a4397', + 'foreign_key' => null, + 'foreign_key_uuid' => 'e5efe749-d6b6-4f72-83c9-32b19936c70c', + 'model' => 'ProductSkus', + 'cart_id' => '21794607-f68e-424f-91ba-3230e2f92e2b', + 'position' => 1, + 'qty' => 1, + 'price' => 1.5, + 'subtotal' => 1.5, + ], + ]; + parent::init(); + } +} diff --git a/tests/Fixture/CartsFixture.php b/tests/Fixture/CartsFixture.php new file mode 100644 index 0000000..2ec55fc --- /dev/null +++ b/tests/Fixture/CartsFixture.php @@ -0,0 +1,77 @@ +records = [ + // normal cart - open + [ + 'id' => '74d1aa54-92a2-4039-bc10-61e1190c51ea', + 'cart_type_id' => CartTypeId::Cart->value, + 'session_id' => 'session_1', + 'user_id' => '5a34a6ae-7d3f-4dcf-bac7-57335b51e697', + 'created' => '2025-10-08 09:55:15', + 'modified' => '2025-10-08 09:55:15', + 'deleted' => null, + 'removed' => null, + 'removed_reason_id' => null, + 'num_items' => 1, + ], + // normal cart - deleted + [ + 'id' => '74d1aa54-92a2-4039-bc10-61e1190c51eb', + 'cart_type_id' => CartTypeId::Cart->value, + 'session_id' => 'session_1', + 'user_id' => '5a34a6ae-7d3f-4dcf-bac7-57335b51e697', + 'created' => '2025-10-08 09:55:15', + 'modified' => '2025-10-08 09:55:15', + 'deleted' => '2025-10-08 09:55:15', + 'removed' => null, + 'removed_reason_id' => null, + 'num_items' => 1, + ], + // wishlist cart - open + [ + 'id' => '74d1aa54-92a2-4039-bc10-61e1190c51ec', + 'cart_type_id' => CartTypeId::Wishlist->value, + 'session_id' => 'session_2', + 'user_id' => '5a34a6ae-7d3f-4dcf-bac7-57335b51e697', + 'created' => '2025-10-08 09:55:15', + 'modified' => '2025-10-08 09:55:15', + 'deleted' => null, + 'removed' => null, + 'removed_reason_id' => null, + 'num_items' => 1, + ], + // normal cart - deleted + [ + 'id' => '74d1aa54-92a2-4039-bc10-61e1190c51eb', + 'cart_type_id' => CartTypeId::Cart->value, + 'session_id' => 'session_2', + 'user_id' => '5a34a6ae-7d3f-4dcf-bac7-57335b51e697', + 'created' => '2025-10-08 09:55:15', + 'modified' => '2025-10-08 09:55:15', + 'deleted' => '2025-10-08 09:55:15', + 'removed' => null, + 'removed_reason_id' => null, + 'num_items' => 1, + ], + ]; + parent::init(); + } +} diff --git a/tests/TestCase/Controller/CartItemsControllerTest.php b/tests/TestCase/Controller/CartItemsControllerTest.php new file mode 100644 index 0000000..4c97806 --- /dev/null +++ b/tests/TestCase/Controller/CartItemsControllerTest.php @@ -0,0 +1,83 @@ + + */ + protected array $fixtures = [ + 'plugin.CakeCarts.CartItems', + 'plugin.CakeCarts.Carts', + ]; + + /** + * Test index method + * + * @return void + * @link \CakeCarts\Controller\CartItemsController::index() + */ + public function testIndex(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test view method + * + * @return void + * @link \CakeCarts\Controller\CartItemsController::view() + */ + public function testView(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test add method + * + * @return void + * @link \CakeCarts\Controller\CartItemsController::add() + */ + public function testAdd(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test edit method + * + * @return void + * @link \CakeCarts\Controller\CartItemsController::edit() + */ + public function testEdit(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test delete method + * + * @return void + * @link \CakeCarts\Controller\CartItemsController::delete() + */ + public function testDelete(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } +} diff --git a/tests/TestCase/Controller/CartsControllerTest.php b/tests/TestCase/Controller/CartsControllerTest.php new file mode 100644 index 0000000..d9f8724 --- /dev/null +++ b/tests/TestCase/Controller/CartsControllerTest.php @@ -0,0 +1,18 @@ +ShoppingCart = new ShoppingCartComponent($registry); + } + + /** + * tearDown method + * + * @return void + */ + protected function tearDown(): void + { + unset($this->ShoppingCart); + + parent::tearDown(); + } +} diff --git a/tests/TestCase/Model/Table/CartItemsTableTest.php b/tests/TestCase/Model/Table/CartItemsTableTest.php new file mode 100644 index 0000000..0872389 --- /dev/null +++ b/tests/TestCase/Model/Table/CartItemsTableTest.php @@ -0,0 +1,76 @@ + + */ + protected array $fixtures = [ + 'plugin.CakeCarts.CartItems', + 'plugin.CakeCarts.Carts', + ]; + + /** + * setUp method + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $config = $this->getTableLocator()->exists('CartItems') ? [] : ['className' => CartItemsTable::class]; + $this->CartItems = $this->getTableLocator()->get('CartItems', $config); + } + + /** + * tearDown method + * + * @return void + */ + protected function tearDown(): void + { + unset($this->CartItems); + + parent::tearDown(); + } + + /** + * Test validationDefault method + * + * @return void + * @uses \CakeCarts\Model\Table\CartItemsTable::validationDefault() + */ + public function testValidationDefault(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test buildRules method + * + * @return void + * @uses \CakeCarts\Model\Table\CartItemsTable::buildRules() + */ + public function testBuildRules(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } +} diff --git a/tests/TestCase/Model/Table/CartsTableTest.php b/tests/TestCase/Model/Table/CartsTableTest.php new file mode 100644 index 0000000..8631546 --- /dev/null +++ b/tests/TestCase/Model/Table/CartsTableTest.php @@ -0,0 +1,77 @@ + + */ + protected array $fixtures = [ + 'plugin.CakeCarts.Carts', + 'plugin.CakeCarts.Users', + 'plugin.CakeCarts.CartItems', + ]; + + /** + * setUp method + * + * @return void + */ + protected function setUp(): void + { + parent::setUp(); + $config = $this->getTableLocator()->exists('Carts') ? [] : ['className' => CartsTable::class]; + $this->Carts = $this->getTableLocator()->get('Carts', $config); + } + + /** + * tearDown method + * + * @return void + */ + protected function tearDown(): void + { + unset($this->Carts); + + parent::tearDown(); + } + + /** + * Test validationDefault method + * + * @return void + * @uses \CakeCarts\Model\Table\CartsTable::validationDefault() + */ + public function testValidationDefault(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } + + /** + * Test buildRules method + * + * @return void + * @uses \CakeCarts\Model\Table\CartsTable::buildRules() + */ + public function testBuildRules(): void + { + $this->markTestIncomplete('Not implemented yet.'); + } +} diff --git a/webroot/.gitkeep b/webroot/.gitkeep new file mode 100644 index 0000000..e69de29