diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml index 0512c8f..e9b1ea8 100644 --- a/.github/actions/run-tests/action.yml +++ b/.github/actions/run-tests/action.yml @@ -23,7 +23,12 @@ runs: - name: Install dependencies shell: bash run: | - composer install --no-dev --optimize-autoloader + composer install --optimize-autoloader + + - name: Run unit tests + shell: bash + run: | + vendor/bin/phpunit Test/Unit/ --testdox - name: Run integration tests shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82a5fe7..0cb83f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,11 @@ jobs: - name: Install dependencies run: | - composer install --no-dev --optimize-autoloader + composer install --optimize-autoloader + + - name: Run Unit Tests + run: | + vendor/bin/phpunit Test/Unit/ --testdox - name: Run Integration Tests run: | diff --git a/Makefile b/Makefile index 463f094..0e055c5 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,11 @@ -.PHONY: test test-local test-compatibility lint lint-fix help +.PHONY: test test-local test-unit test-compatibility lint lint-fix help # Default target help: @echo "Available commands:" @echo " make test - Run all tests using Docker" @echo " make test-local - Run all tests locally (requires PHP)" + @echo " make test-unit - Run unit tests only" @echo " make test-compatibility - Run Adobe Commerce 2.4.8-p1 compatibility tests" @echo " make lint - Run PHP CodeSniffer linting" @echo " make lint-fix - Auto-fix linting issues where possible" @@ -18,11 +19,17 @@ test: # Run tests locally (requires PHP) test-local: @echo "Running all tests locally..." + @vendor/bin/phpunit Test/Unit/ --testdox @for test_file in Test/Integration/*.php; do \ echo "Running $$test_file..."; \ php "$$test_file"; \ done +# Run unit tests only +test-unit: + @echo "Running unit tests..." + @vendor/bin/phpunit Test/Unit/ --testdox + # Run Adobe Commerce 2.4.8-p1 compatibility tests test-compatibility: @echo "Running Adobe Commerce 2.4.8-p1 compatibility tests..." diff --git a/Model/Api.php b/Model/Api.php index 588ab20..ba4f798 100644 --- a/Model/Api.php +++ b/Model/Api.php @@ -116,6 +116,13 @@ class Api */ private $cartItemResponseHandler; + /** + * Product TIC Service + * + * @var \Taxcloud\Magento2\Model\ProductTicService + */ + private $productTicService; + /** * @param \Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig * @param \Magento\Framework\App\CacheInterface $cacheType @@ -125,6 +132,9 @@ class Api * @param \Magento\Catalog\Model\ProductFactory $productFactory * @param \Magento\Directory\Model\RegionFactory $regionFactory * @param \Taxcloud\Magento2\Logger\Logger $tclogger + * @param SerializerInterface $serializer + * @param \Taxcloud\Magento2\Model\CartItemResponseHandler $cartItemResponseHandler + * @param \Taxcloud\Magento2\Model\ProductTicService $productTicService * @SuppressWarnings(PHPMD.ExcessiveParameterList) */ public function __construct( @@ -137,7 +147,8 @@ public function __construct( \Magento\Directory\Model\RegionFactory $regionFactory, \Taxcloud\Magento2\Logger\Logger $tclogger, SerializerInterface $serializer, - \Taxcloud\Magento2\Model\CartItemResponseHandler $cartItemResponseHandler + \Taxcloud\Magento2\Model\CartItemResponseHandler $cartItemResponseHandler, + \Taxcloud\Magento2\Model\ProductTicService $productTicService ) { $this->_scopeConfig = $scopeConfig; $this->_cacheType = $cacheType; @@ -148,6 +159,7 @@ public function __construct( $this->_regionFactory = $regionFactory; $this->serializer = $serializer; $this->cartItemResponseHandler = $cartItemResponseHandler; + $this->productTicService = $productTicService; if ($scopeConfig->getValue('tax/taxcloud_settings/logging', \Magento\Store\Model\ScopeInterface::SCOPE_STORE)) { $this->_tclogger = $tclogger; } else { @@ -186,23 +198,6 @@ protected function _getGuestCustomerId() return $this->_scopeConfig->getValue('tax/taxcloud_settings/guest_customer_id', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) ?? '-1'; } - /** - * Get TaxCloud Default Product TIC - * @return string - */ - protected function _getDefaultTic() - { - return $this->_scopeConfig->getValue('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) ?? '00000'; - } - - /** - * Get TaxCloud Default Shipping TIC - * @return string - */ - protected function _getShippingTic() - { - return $this->_scopeConfig->getValue('tax/taxcloud_settings/shipping_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) ?? '11010'; - } /** * Get TaxCloud Cache Lifetime @@ -331,16 +326,14 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) if (isset($itemsByType[self::ITEM_TYPE_PRODUCT])) { foreach ($itemsByType[self::ITEM_TYPE_PRODUCT] as $code => $itemTaxDetail) { $item = $keyedAddressItems[$code]; - if ($item->getProduct()->getTaxClassId() === '0') { + if ($item->getProduct() && $item->getProduct()->getTaxClassId() === '0') { // Skip products with tax_class_id of None, store owners should avoid doing this continue; } - $productModel = $this->_productFactory->create()->load($item->getProduct()->getId()); - $tic = $productModel->getCustomAttribute('taxcloud_tic'); $cartItems[] = array( 'ItemID' => $item->getSku(), 'Index' => $index, - 'TIC' => $tic ? $tic->getValue() : $this->_getDefaultTic(), + 'TIC' => $this->productTicService->getProductTic($item, 'lookupTaxes'), 'Price' => $item->getPrice() - $item->getDiscountAmount() / $item->getQty(), 'Qty' => $item->getQty(), ); @@ -354,7 +347,7 @@ public function lookupTaxes($itemsByType, $shippingAssignment, $quote) $cartItems[] = array( 'ItemID' => 'shipping', 'Index' => $index++, - 'TIC' => $this->_getShippingTic(), + 'TIC' => $this->productTicService->getShippingTic(), 'Price' => $itemTaxDetail[self::KEY_ITEM]->getRowTotal(), 'Qty' => 1, ); @@ -604,12 +597,10 @@ public function returnOrder($creditmemo) if ($items) { foreach ($items as $creditItem) { $item = $creditItem->getOrderItem(); - $productModel = $this->_productFactory->create()->load($item->getProduct()->getId()); - $tic = $productModel->getCustomAttribute('taxcloud_tic'); $cartItems[] = array( 'ItemID' => $item->getSku(), 'Index' => $index, - 'TIC' => $tic ? $tic->getValue() : $this->_getDefaultTic(), + 'TIC' => $this->productTicService->getProductTic($item, 'returnOrder'), 'Price' => $creditItem->getPrice() - $creditItem->getDiscountAmount() / $creditItem->getQty(), 'Qty' => $creditItem->getQty(), ); @@ -623,7 +614,7 @@ public function returnOrder($creditmemo) $cartItems[] = array( 'ItemID' => 'shipping', 'Index' => $index, - 'TIC' => $this->_getShippingTic(), + 'TIC' => $this->productTicService->getShippingTic(), 'Price' => $shippingAmount, 'Qty' => 1, ); @@ -707,8 +698,12 @@ public function returnOrder($creditmemo) $returnResult = $obj->getResult(); - if ($returnResult['ResponseType'] != 'OK') { - $this->_tclogger->info('Error encountered during returnOrder: ' . $returnResult['Messages']['ResponseMessage']['Message']); + if (!$returnResult || $returnResult['ResponseType'] != 'OK') { + $errorMessage = 'Unknown error'; + if ($returnResult && isset($returnResult['Messages']['ResponseMessage']['Message'])) { + $errorMessage = $returnResult['Messages']['ResponseMessage']['Message']; + } + $this->_tclogger->info('Error encountered during returnOrder: ' . $errorMessage); return false; } diff --git a/Model/ProductTicService.php b/Model/ProductTicService.php new file mode 100644 index 0000000..f1512d8 --- /dev/null +++ b/Model/ProductTicService.php @@ -0,0 +1,138 @@ + + * @copyright 2021 The Federal Tax Authority, LLC d/b/a TaxCloud + * @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0) + */ + +namespace Taxcloud\Magento2\Model; + +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Catalog\Model\ProductFactory; +use Taxcloud\Magento2\Logger\Logger; + +/** + * Service for handling Product TIC (Taxability Information Code) logic + * Handles cases where products have been deleted or don't have custom TIC attributes + */ +class ProductTicService +{ + /** + * Default TIC fallback value when configuration is empty or null + */ + const DEFAULT_TIC = '00000'; + + /** + * Default shipping TIC fallback value when configuration is empty or null + */ + const DEFAULT_SHIPPING_TIC = '11010'; + /** + * @var ScopeConfigInterface + */ + private $scopeConfig; + + /** + * @var ProductFactory + */ + private $productFactory; + + /** + * @var Logger + */ + private $logger; + + /** + * @param ScopeConfigInterface $scopeConfig + * @param ProductFactory $productFactory + * @param Logger $logger + */ + public function __construct( + ScopeConfigInterface $scopeConfig, + ProductFactory $productFactory, + Logger $logger + ) { + $this->scopeConfig = $scopeConfig; + $this->productFactory = $productFactory; + $this->logger = $logger; + } + + /** + * Get product TIC (Taxability Information Code) with null safety + * Handles cases where product has been deleted or doesn't have custom TIC + * + * @param \Magento\Sales\Model\Order\Item $item + * @param string $context Context for logging (e.g., 'lookupTaxes', 'returnOrder') + * @return string The TIC value + */ + public function getProductTic($item, $context = '') + { + $product = $item->getProduct(); + + // Handle case where product has been deleted + if (!$product || !$product->getId()) { + $this->logger->info( + 'Product not found for item ' . $item->getSku() . ' in ' . $context . ', using default TIC' + ); + return $this->getDefaultTic(); + } + + $productModel = $this->productFactory->create()->load($product->getId()); + $tic = $productModel->getCustomAttribute('taxcloud_tic'); + + return $tic ? $tic->getValue() : $this->getDefaultTic(); + } + + /** + * Get the default TIC value from configuration + * Falls back to DEFAULT_TIC if configuration is empty or null + * + * @return string + */ + public function getDefaultTic() + { + $value = $this->scopeConfig->getValue( + 'tax/taxcloud_settings/default_tic', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + + return ($value !== null && $value !== '') ? $value : self::DEFAULT_TIC; + } + + /** + * Check if a product exists and is valid + * + * @param \Magento\Sales\Model\Order\Item $item + * @return bool + */ + public function isProductValid($item) + { + $product = $item->getProduct(); + return $product && $product->getId(); + } + + /** + * Get the shipping TIC value from configuration + * Falls back to DEFAULT_SHIPPING_TIC if configuration is empty or null + * + * @return string + */ + public function getShippingTic() + { + $value = $this->scopeConfig->getValue( + 'tax/taxcloud_settings/shipping_tic', + \Magento\Store\Model\ScopeInterface::SCOPE_STORE + ); + + return ($value !== null && $value !== '') ? $value : self::DEFAULT_SHIPPING_TIC; + } +} diff --git a/Test/Unit/Mocks/MagentoMocks.php b/Test/Unit/Mocks/MagentoMocks.php new file mode 100644 index 0000000..a8ace2e --- /dev/null +++ b/Test/Unit/Mocks/MagentoMocks.php @@ -0,0 +1,268 @@ +regionFactory = $this->createMock(RegionFactory::class); $this->logger = $this->createMock(Logger::class); $this->serializer = $this->createMock(SerializerInterface::class); - $this->mockSoapClient = $this->createMock(\SoapClient::class); - $this->mockDataObject = $this->createMock(DataObject::class); + $this->cartItemResponseHandler = $this->createMock(CartItemResponseHandler::class); + $this->productTicService = $this->createMock(ProductTicService::class); + $this->mockSoapClient = $this->getMockBuilder(\SoapClient::class) + ->disableOriginalConstructor() + ->addMethods(['Returned']) + ->getMock(); + $this->mockDataObject = $this->getMockBuilder(DataObject::class) + ->disableOriginalConstructor() + ->addMethods(['setParams', 'getParams', 'setResult', 'getResult']) + ->getMock(); $this->api = new Api( $this->scopeConfig, @@ -68,7 +80,9 @@ protected function setUp(): void $this->productFactory, $this->regionFactory, $this->logger, - $this->serializer + $this->serializer, + $this->cartItemResponseHandler, + $this->productTicService ); } @@ -95,7 +109,7 @@ public function testReturnOrderIncludesReturnCoDeliveryFeeWhenNoCartItems() // Mock credit memo $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); - $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); $creditmemo->method('getOrder')->willReturn($order); @@ -157,7 +171,7 @@ public function testReturnOrderHandlesSoapErrorGracefully() // Mock credit memo $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); - $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); $creditmemo->method('getOrder')->willReturn($order); @@ -209,7 +223,7 @@ public function testReturnOrderWithCartItems() // Mock credit memo with items $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); - $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); $creditItem = $this->createMock(\Magento\Sales\Model\Order\Creditmemo\Item::class); @@ -312,7 +326,7 @@ public function testReturnOrderFailsWhenParameterIsLost() // Mock credit memo $creditmemo = $this->createMock(\Magento\Sales\Model\Order\Creditmemo::class); - $order = $this->createMock(\Magento\Sales\Model\Order::class); + $order = $this->createMock(\Magento\Sales\Model\Order\Order::class); $order->method('getIncrementId')->willReturn('TEST_ORDER_123'); $creditmemo->method('getOrder')->willReturn($order); diff --git a/Test/Unit/Model/ProductTicServiceTest.php b/Test/Unit/Model/ProductTicServiceTest.php new file mode 100644 index 0000000..c3397af --- /dev/null +++ b/Test/Unit/Model/ProductTicServiceTest.php @@ -0,0 +1,260 @@ + + * @copyright 2021 The Federal Tax Authority, LLC d/b/a TaxCloud + * @license http://opensource.org/licenses/osl-3.0.php Open Software License (OSL 3.0) + */ + +namespace Taxcloud\Magento2\Test\Unit\Model; + +// Load Magento mocks before any other includes +require_once __DIR__ . '/../Mocks/MagentoMocks.php'; + +// Load the ProductTicService class directly +require_once __DIR__ . '/../../../Model/ProductTicService.php'; + +use PHPUnit\Framework\TestCase; +use Taxcloud\Magento2\Model\ProductTicService; +use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Catalog\Model\ProductFactory; +use Magento\Catalog\Model\Product; +use Magento\Sales\Model\Order\Item; +use Magento\Framework\Api\AttributeValue; +use Taxcloud\Magento2\Logger\Logger; + +class ProductTicServiceTest extends TestCase +{ + private $productTicService; + private $scopeConfig; + private $productFactory; + private $logger; + + protected function setUp(): void + { + $this->scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->productFactory = $this->createMock(ProductFactory::class); + $this->logger = $this->createMock(Logger::class); + + $this->productTicService = new ProductTicService( + $this->scopeConfig, + $this->productFactory, + $this->logger + ); + } + + /** + * Test getProductTic with valid product having custom TIC + */ + public function testGetProductTicWithValidProductAndCustomTic() + { + $item = $this->createMock(Item::class); + $product = $this->createMock(Product::class); + $productModel = $this->createMock(Product::class); + $customAttribute = $this->createMock(AttributeValue::class); + + $item->method('getSku')->willReturn('TEST_SKU'); + $item->method('getProduct')->willReturn($product); + + $product->method('getId')->willReturn(123); + + $productModel->method('load')->with(123)->willReturnSelf(); + $productModel->method('getCustomAttribute')->with('taxcloud_tic')->willReturn($customAttribute); + + $customAttribute->method('getValue')->willReturn('20000'); + + $this->productFactory->method('create')->willReturn($productModel); + + $result = $this->productTicService->getProductTic($item, 'testContext'); + + $this->assertEquals('20000', $result, 'Should return custom TIC value when product has custom TIC'); + } + + /** + * Test getProductTic with deleted/null product + */ + public function testGetProductTicWithDeletedProduct() + { + $item = $this->createMock(Item::class); + $item->method('getSku')->willReturn('DELETED_SKU'); + $item->method('getProduct')->willReturn(null); + + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('00000'); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Product not found for item DELETED_SKU in testContext, using default TIC'); + + $result = $this->productTicService->getProductTic($item, 'testContext'); + + $this->assertEquals('00000', $result, 'Should return default TIC when product is null'); + } + + /** + * Test getProductTic with product having no ID + */ + public function testGetProductTicWithProductHavingNoId() + { + $item = $this->createMock(Item::class); + $product = $this->createMock(Product::class); + + $item->method('getSku')->willReturn('NO_ID_SKU'); + $item->method('getProduct')->willReturn($product); + $product->method('getId')->willReturn(null); + + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('00000'); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Product not found for item NO_ID_SKU in testContext, using default TIC'); + + $result = $this->productTicService->getProductTic($item, 'testContext'); + + $this->assertEquals('00000', $result, 'Should return default TIC when product has no ID'); + } + + /** + * Test getProductTic with product having no custom TIC attribute + */ + public function testGetProductTicWithNoCustomTicAttribute() + { + $item = $this->createMock(Item::class); + $product = $this->createMock(Product::class); + $productModel = $this->createMock(Product::class); + + $item->method('getSku')->willReturn('NO_TIC_SKU'); + $item->method('getProduct')->willReturn($product); + + $product->method('getId')->willReturn(456); + + $productModel->method('load')->with(456)->willReturnSelf(); + $productModel->method('getCustomAttribute')->with('taxcloud_tic')->willReturn(null); + + $this->productFactory->method('create')->willReturn($productModel); + + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('00000'); + + $result = $this->productTicService->getProductTic($item, 'testContext'); + + $this->assertEquals('00000', $result, 'Should return default TIC when product has no custom TIC attribute'); + } + + /** + * Test getProductTic with empty context parameter + */ + public function testGetProductTicWithEmptyContext() + { + $item = $this->createMock(Item::class); + $item->method('getSku')->willReturn('TEST_SKU'); + $item->method('getProduct')->willReturn(null); + + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn('00000'); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Product not found for item TEST_SKU in , using default TIC'); + + $result = $this->productTicService->getProductTic($item, ''); + + $this->assertEquals('00000', $result, 'Should return default TIC when context is empty'); + } + + /** + * Test getDefaultTic with various configurations + * @dataProvider defaultTicProvider + */ + public function testGetDefaultTic($configValue, $expectedResult, $description) + { + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/default_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn($configValue); + + $result = $this->productTicService->getDefaultTic(); + + $this->assertEquals($expectedResult, $result, $description); + } + + public function defaultTicProvider() + { + return [ + 'configured value' => ['12345', '12345', 'Should return configured default TIC value'], + 'null configuration' => [null, '00000', 'Should return fallback default TIC value when configuration is null'], + 'empty string' => ['', '00000', 'Should return fallback default TIC value when configuration is empty'], + 'zero value' => ['0', '0', 'Should return zero if configured as zero'], + ]; + } + + /** + * Test isProductValid with various product states + * @dataProvider productValidationProvider + */ + public function testIsProductValid($productId, $expectedResult, $description) + { + $item = $this->createMock(Item::class); + + if ($productId === 'null_product') { + $item->method('getProduct')->willReturn(null); + } else { + $product = $this->createMock(Product::class); + $item->method('getProduct')->willReturn($product); + $product->method('getId')->willReturn($productId); + } + + $result = $this->productTicService->isProductValid($item); + + $this->assertEquals($expectedResult, $result, $description); + } + + public function productValidationProvider() + { + return [ + 'valid product with ID' => [789, true, 'Should return true for valid product with ID'], + 'null product' => ['null_product', false, 'Should return false for null product'], + 'product with no ID' => [null, false, 'Should return false for product with no ID'], + 'product with zero ID' => [0, false, 'Should return false for product with zero ID'], + ]; + } + + /** + * Test getShippingTic with various configurations + * @dataProvider shippingTicProvider + */ + public function testGetShippingTic($configValue, $expectedResult, $description) + { + $this->scopeConfig->method('getValue') + ->with('tax/taxcloud_settings/shipping_tic', \Magento\Store\Model\ScopeInterface::SCOPE_STORE) + ->willReturn($configValue); + + $result = $this->productTicService->getShippingTic(); + + $this->assertEquals($expectedResult, $result, $description); + } + + public function shippingTicProvider() + { + return [ + 'default configured value' => ['11010', '11010', 'Should return configured shipping TIC value'], + 'null configuration' => [null, '11010', 'Should return fallback shipping TIC value when configuration is null'], + 'custom configured value' => ['99999', '99999', 'Should return custom configured shipping TIC value'], + 'empty string' => ['', '11010', 'Should return fallback shipping TIC value when configuration is empty'], + ]; + } + +} diff --git a/Test/Unit/bootstrap.php b/Test/Unit/bootstrap.php new file mode 100644 index 0000000..e7129ef --- /dev/null +++ b/Test/Unit/bootstrap.php @@ -0,0 +1,11 @@ +