Skip to content

Commit

Permalink
Merge branch '2.4-develop' of https://github.com/mage-os/mirror-magento2
Browse files Browse the repository at this point in the history
 into 2.4-develop
mage-os-ci committed Jan 23, 2025
2 parents 3b5cd31 + 3f12d15 commit 8498d19
Showing 16 changed files with 845 additions and 174 deletions.
74 changes: 51 additions & 23 deletions app/code/Magento/Authorization/Model/Acl/Loader/Rule.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2012 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

@@ -87,7 +87,7 @@ public function __construct(
public function populateAcl(Acl $acl)
{
$result = $this->applyPermissionsAccordingToRules($acl);
$this->applyDenyPermissionsForMissingRules($acl, ...$result);
$this->denyPermissionsForMissingRules($acl, $result);
}

/**
@@ -98,56 +98,84 @@ public function populateAcl(Acl $acl)
*/
private function applyPermissionsAccordingToRules(Acl $acl): array
{
$foundResources = $foundDeniedRoles = [];
$appliedRolePermissionsPerResource = [];
foreach ($this->getRulesArray() as $rule) {
$role = $rule['role_id'];
$resource = $rule['resource_id'];
$privileges = !empty($rule['privileges']) ? explode(',', $rule['privileges']) : null;

if ($acl->hasResource($resource)) {
$foundResources[$resource] = $resource;

$appliedRolePermissionsPerResource[$resource]['allow'] =
$appliedRolePermissionsPerResource[$resource]['allow'] ?? [];
$appliedRolePermissionsPerResource[$resource]['deny'] =
$appliedRolePermissionsPerResource[$resource]['deny'] ?? [];

if ($rule['permission'] == 'allow') {
if ($resource === $this->_rootResource->getId()) {
$acl->allow($role, null, $privileges);
}
$acl->allow($role, $resource, $privileges);
$appliedRolePermissionsPerResource[$resource]['allow'][] = $role;
} elseif ($rule['permission'] == 'deny') {
$foundDeniedRoles[$role] = $role;
$acl->deny($role, $resource, $privileges);
$appliedRolePermissionsPerResource[$resource]['deny'][] = $role;
}
}
}
return [$foundResources, $foundDeniedRoles];

return $appliedRolePermissionsPerResource;
}

/**
* Apply deny permissions for missing rules
* Deny permissions for missing rules
*
* For all rules that were not regenerated in authorization_rule table,
* when adding a new module and without re-saving all roles,
* consider not present rules with deny permissions
*
* @param Acl $acl
* @param array $resources
* @param array $deniedRoles
* @param array $appliedRolePermissionsPerResource
* @return void
*/
private function applyDenyPermissionsForMissingRules(
Acl $acl,
array $resources,
array $deniedRoles
private function denyPermissionsForMissingRules(
Acl $acl,
array $appliedRolePermissionsPerResource,
) {
if (count($resources) && count($deniedRoles)
//ignore denying missing permission if all are allowed
&& !(count($resources) === 1 && isset($resources[static::ALLOW_EVERYTHING]))
) {
foreach ($acl->getResources() as $resource) {
if (!isset($resources[$resource])) {
foreach ($deniedRoles as $role) {
$acl->deny($role, $resource, null);
}
$consolidatedDeniedRoleIds = array_unique(
array_merge(
...array_column($appliedRolePermissionsPerResource, 'deny')
)
);

$hasAppliedPermissions = count($appliedRolePermissionsPerResource) > 0;
$hasDeniedRoles = count($consolidatedDeniedRoleIds) > 0;
$allAllowed = count($appliedRolePermissionsPerResource) === 1
&& isset($appliedRolePermissionsPerResource[static::ALLOW_EVERYTHING]);

if ($hasAppliedPermissions && $hasDeniedRoles && !$allAllowed) {
// Add the resources that are not present in the rules at all,
// assuming that they must be denied for all roles by default
$resourcesUndefinedInAuthorizationRules =
array_diff($acl->getResources(), array_keys($appliedRolePermissionsPerResource));
$assumeDeniedRoleListPerResource =
array_fill_keys($resourcesUndefinedInAuthorizationRules, $consolidatedDeniedRoleIds);

// Add the resources that are permitted for one role and not present in others at all,
// assuming that they must be denied for all other roles by default
foreach ($appliedRolePermissionsPerResource as $resource => $permissions) {
$allowedRoles = $permissions['allow'];
$deniedRoles = $permissions['deny'];
$assumedDeniedRoles = array_diff($consolidatedDeniedRoleIds, $allowedRoles, $deniedRoles);
if ($assumedDeniedRoles) {
$assumeDeniedRoleListPerResource[$resource] = $assumedDeniedRoles;
}
}

// Deny permissions for missing rules
foreach ($assumeDeniedRoleListPerResource as $resource => $denyRoles) {
$acl->deny($denyRoles, $resource, null);
}
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?php
/**
* Copyright 2024 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\Authorization\Test\Unit\Model\Acl\Loader;

use Magento\Authorization\Model\Acl\Loader\Rule;
use Magento\Framework\Acl;
use Magento\Framework\Acl\Data\CacheInterface;
use Magento\Framework\Acl\RootResource;
use Magento\Framework\App\ResourceConnection;
use Magento\Framework\Serialize\Serializer\Json;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

/**
* @covers \Magento\Authorization\Model\Acl\Loader\Rule
*/
class MissingDeclineRuleTest extends TestCase
{
/**
* @var Rule
*/
private $model;

/**
* @var RootResource
*/
private $rootResource;

/**
* @var ResourceConnection|MockObject
*/
private $resourceMock;

/**
* @var CacheInterface|MockObject
*/
private $aclDataCacheMock;

/**
* @var Json|MockObject
*/
private $serializerMock;

/**
* @inheritDoc
*/
protected function setUp(): void
{
$this->rootResource = new RootResource('Magento_Backend::all');

$this->resourceMock = $this->getMockBuilder(ResourceConnection::class)
->addMethods(['getTable'])
->onlyMethods(['getConnection'])
->disableOriginalConstructor()
->getMock();

$this->aclDataCacheMock = $this->createMock(CacheInterface::class);
$this->serializerMock = $this->createPartialMock(
Json::class,
['serialize', 'unserialize']
);

$this->serializerMock->method('serialize')
->willReturnCallback(
static function ($value) {
return json_encode($value);
}
);

$this->serializerMock->method('unserialize')
->willReturnCallback(
static function ($value) {
return json_decode($value, true);
}
);

$this->model = new Rule(
$this->rootResource,
$this->resourceMock,
$this->aclDataCacheMock,
$this->serializerMock
);
}

/**
* This test ensures that any new resources, which have not been explicitly defined in the authorization_rule table,
* are automatically denied for all roles unless explicitly allowed.
*
* @return void
* @throws Exception
*/
public function testDenyAbsentResources(): void
{
// Vendor_MyModule::menu and Vendor_MyModule::report permissions are not present in the authorization_rules
// table for role 3, and should be denied by default
$authorizationRulesData = [
['rule_id' => 1, 'role_id' => 2, 'resource_id' => 'Magento_Backend::all', 'permission' => 'deny'],
['rule_id' => 2, 'role_id' => 2, 'resource_id' => 'Vendor_MyModule::index', 'permission' => 'allow'],
['rule_id' => 3, 'role_id' => 2, 'resource_id' => 'Vendor_MyModule::menu', 'permission' => 'deny'],
['rule_id' => 4, 'role_id' => 2, 'resource_id' => 'Vendor_MyModule::report', 'permission' => 'deny'],
['rule_id' => 5, 'role_id' => 3, 'resource_id' => 'Magento_Backend::all', 'permission' => 'deny'],
['rule_id' => 6, 'role_id' => 3, 'resource_id' => 'Vendor_MyModule::index', 'permission' => 'allow'],
];

// Vendor_MyModule::configuration is a new resource that has not been defined in the authorization_rules table
// for any role, and should be denied by default
$getAclResourcesData = array_unique(array_column($authorizationRulesData, 'resource_id'));
$getAclResourcesData[] = 'Vendor_MyModule::configuration';

$this->aclDataCacheMock->expects($this->once())
->method('load')
->with(Rule::ACL_RULE_CACHE_KEY)
->willReturn(
json_encode($authorizationRulesData)
);

$aclMock = $this->createMock(Acl::class);
$aclMock->method('hasResource')->willReturn(true);

$aclMock
->expects($this->exactly(2))
->method('allow');

$aclMock
->expects($this->exactly(7))
->method('deny');

$aclMock
->method('getResources')
->willReturn($getAclResourcesData);

$this->model->populateAcl($aclMock);
}
}
37 changes: 17 additions & 20 deletions app/code/Magento/Backend/App/Area/FrontNameResolver.php
Original file line number Diff line number Diff line change
@@ -121,6 +121,10 @@ public function getFrontName($checkHost = false)
*/
public function isHostBackend()
{
if (!$this->request->getServer('HTTP_HOST')) {
return false;
}

if ($this->scopeConfig->getValue(self::XML_PATH_USE_CUSTOM_ADMIN_URL, ScopeInterface::SCOPE_STORE)) {
$backendUrl = $this->scopeConfig->getValue(self::XML_PATH_CUSTOM_ADMIN_URL, ScopeInterface::SCOPE_STORE);
} else {
@@ -132,28 +136,21 @@ public function isHostBackend()
);
}
}
$host = (string) $this->request->getServer('HTTP_HOST', '');
$hostWithPort = $this->getHostWithPort($backendUrl);

return !($hostWithPort === null || $host === '') && stripos($hostWithPort, $host) !== false;
}
$this->uri->parse($backendUrl);
$configuredHost = $this->uri->getHost();
if (!$configuredHost) {
return false;
}

/**
* Get host with port
*
* @param string $url
* @return mixed|string
*/
private function getHostWithPort($url)
{
$this->uri->parse($url);
$scheme = $this->uri->getScheme();
$configuredPort = $this->uri->getPort() ?: ($this->standardPorts[$this->uri->getScheme()] ?? null);
$uri = ($this->request->isSecure() ? 'https' : 'http') . '://' . $this->request->getServer('HTTP_HOST');
$this->uri->parse($uri);
$host = $this->uri->getHost();
$port = $this->uri->getPort();

if (!$port) {
$port = $this->standardPorts[$scheme] ?? null;
if ($configuredPort) {
$configuredHost .= ':' . $configuredPort;
$host .= ':' . ($this->uri->getPort() ?: $this->standardPorts[$this->uri->getScheme()]);
}
return $port !== null ? $host . ':' . $port : $host;

return strcasecmp($configuredHost, $host) === 0;
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2013 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

@@ -62,12 +62,10 @@ protected function setUp(): void
->method('get')
->with(ConfigOptionsList::CONFIG_PATH_BACKEND_FRONTNAME)
->willReturn($this->_defaultFrontName);
$this->uri = $this->createMock(Uri::class);

$this->uri = $this->createPartialMock(Uri::class, ['parse']);
$this->request = $this->createMock(Http::class);

$this->configMock = $this->createMock(Config::class);
$this->scopeConfigMock = $this->getMockForAbstractClass(ScopeConfigInterface::class);
$this->scopeConfigMock = $this->createMock(ScopeConfigInterface::class);
$this->model = new FrontNameResolver(
$this->configMock,
$deploymentConfigMock,
@@ -111,6 +109,7 @@ public function testIfCustomPathNotUsed(): void
/**
* @param string $url
* @param string|null $host
* @param bool $isHttps
* @param string $useCustomAdminUrl
* @param string $customAdminUrl
* @param bool $expectedValue
@@ -121,12 +120,12 @@ public function testIfCustomPathNotUsed(): void
public function testIsHostBackend(
string $url,
?string $host,
bool $isHttps,
string $useCustomAdminUrl,
string $customAdminUrl,
bool $expectedValue
): void {
$this->scopeConfigMock->expects($this->exactly(2))
->method('getValue')
$this->scopeConfigMock->method('getValue')
->willReturnMap(
[
[Store::XML_PATH_UNSECURE_BASE_URL, ScopeInterface::SCOPE_STORE, null, $url],
@@ -145,41 +144,24 @@ public function testIsHostBackend(
]
);

$this->request->expects($this->any())
$this->request->expects($this->atLeastOnce())
->method('getServer')
->willReturn($host);

$urlParts = [];
$this->uri->expects($this->once())
->method('parse')
->willReturnCallback(
function ($url) use (&$urlParts) {
$urlParts = parse_url($url);
}
);
$this->uri->expects($this->once())
->method('getScheme')
->willReturnCallback(
function () use (&$urlParts) {
return array_key_exists('scheme', $urlParts) ? $urlParts['scheme'] : '';
}
);
$this->uri->expects($this->once())
->method('getHost')
->willReturnCallback(
function () use (&$urlParts) {
return array_key_exists('host', $urlParts) ? $urlParts['host'] : '';
}
->willReturnMap(
[
['HTTP_HOST', null, $host],
]
);
$this->uri->expects($this->once())
->method('getPort')
$this->request->method('isSecure')
->willReturn($isHttps);

$this->uri->method('parse')
->willReturnCallback(
function () use (&$urlParts) {
return array_key_exists('port', $urlParts) ? $urlParts['port'] : '';
}
fn ($url) => $this->uri->setScheme(parse_url($url, PHP_URL_SCHEME))
->setHost(parse_url($url, PHP_URL_HOST))
->setPort(parse_url($url, PHP_URL_PORT))
);

$this->assertEquals($this->model->isHostBackend(), $expectedValue);
$this->assertEquals($expectedValue, $this->model->isHostBackend());
}

/**
@@ -192,11 +174,8 @@ public function testIsHostBackendWithEmptyHost(): void
$this->request->expects($this->any())
->method('getServer')
->willReturn('magento2.loc');
$this->uri->expects($this->once())
->method('getHost')
->willReturn(null);

$this->assertEquals($this->model->isHostBackend(), false);
$this->assertFalse($this->model->isHostBackend());
}

/**
@@ -208,62 +187,71 @@ public static function hostsDataProvider(): array
'withoutPort' => [
'url' => 'http://magento2.loc/',
'host' => 'magento2.loc',
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => true
],
'withPort' => [
'url' => 'http://magento2.loc:8080/',
'host' => 'magento2.loc:8080',
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => true
],
'withStandartPortInUrlWithoutPortInHost' => [
'url' => 'http://magento2.loc:80/',
'host' => 'magento2.loc',
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => true
],
'withoutStandartPortInUrlWithPortInHost' => [
'url' => 'https://magento2.loc/',
'host' => 'magento2.loc:443',
'isHttps' => true,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => true
],
'differentHosts' => [
'url' => 'http://m2.loc/',
'host' => 'magento2.loc',
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => false
],
'differentPortsOnOneHost' => [
'url' => 'http://magento2.loc/',
'host' => 'magento2.loc:8080',
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => false
],
'withCustomAdminUrl' => [
'url' => 'http://magento2.loc/',
'host' => 'myhost.loc',
'isHttps' => true,
'useCustomAdminUrl' => '1',
'customAdminUrl' => 'https://myhost.loc/',
'expectedValue' => true
],
'withCustomAdminUrlWrongHost' => [
'url' => 'http://magento2.loc/',
'host' => 'SomeOtherHost.loc',
'isHttps' => false,
'useCustomAdminUrl' => '1',
'customAdminUrl' => 'https://myhost.loc/',
'expectedValue' => false
],
'withEmptyHost' => [
'url' => 'http://magento2.loc/',
'host' => null,
'isHttps' => false,
'useCustomAdminUrl' => '0',
'customAdminUrl' => '',
'expectedValue' => false
12 changes: 10 additions & 2 deletions app/code/Magento/CatalogWidget/Block/Product/ProductsList.php
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2014 Adobe
* All Rights Reserved.
*/

namespace Magento\CatalogWidget\Block\Product;
@@ -621,4 +621,12 @@ private function decodeConditions(string $encodedConditions): array
{
return $this->conditionsHelper->decode(htmlspecialchars_decode($encodedConditions));
}

/**
* @inheritdoc
*/
protected function _afterToHtml($html)
{
return trim($html);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2014 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

@@ -29,7 +29,6 @@
use Magento\Rule\Model\Condition\Sql\Builder;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManagerInterface;

use Magento\Widget\Helper\Conditions;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
@@ -137,7 +136,7 @@ protected function setUp(): void
'conditionsHelper' => $this->widgetConditionsHelper,
'storeManager' => $this->storeManager,
'design' => $this->design,
'json' => $this->serializer
'json' => $this->serializer,
]
);
$this->request = $arguments['context']->getRequest();
Original file line number Diff line number Diff line change
@@ -36,7 +36,7 @@ public function getConfig(DataObject $config) : DataObject
{
$config->addData([
'tinymce' => [
'toolbar' => ' blocks fontfamily fontsize| formatselect | bold italic underline ' .
'toolbar' => ' blocks fontfamily fontsizeinput| formatselect | bold italic underline ' .
'| alignleft aligncenter alignright | bullist numlist | link table charmap',
'plugins' => implode(
' ',
Original file line number Diff line number Diff line change
@@ -38,6 +38,6 @@ public function testGetConfig(): void
$config = new DataObject();
$configProvider = new DefaultConfigProvider($this->assetRepo);
$result = $configProvider->getConfig($config);
$this->assertStringContainsString('fontfamily fontsize', $result->getTinymce()['toolbar']);
$this->assertStringContainsString('fontfamily fontsizeinput', $result->getTinymce()['toolbar']);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2023 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

@@ -10,12 +10,13 @@
use Magento\Customer\Model\Data\Customer;
use Magento\Customer\Model\Data\CustomerFactory;
use Magento\Framework\EntityManager\HydratorPool;
use Magento\Framework\ObjectManager\ResetAfterRequestInterface;
use Magento\GraphQlResolverCache\Model\Resolver\Result\HydratorInterface;

/**
* Customer resolver data hydrator to rehydrate propagated model.
*/
class ModelHydrator implements HydratorInterface
class ModelHydrator implements HydratorInterface, ResetAfterRequestInterface
{
/**
* @var CustomerFactory
@@ -59,4 +60,14 @@ public function hydrate(array &$resolverData): void
$resolverData['model'] = $this->customerModels[$resolverData['model_id']];
}
}

/**
* Reset customerModels
*
* @return void
*/
public function _resetState(): void
{
$this->customerModels = [];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<?php
/**
* Copyright 2024 Adobe.
* All Rights Reserved.
*/
declare(strict_types=1);

namespace Magento\CustomerGraphQl\Test\Unit\Model\Resolver\Cache\Customer;

use Magento\Customer\Model\Data\Customer;
use Magento\Customer\Model\Data\CustomerFactory;
use Magento\CustomerGraphQl\Model\Resolver\Cache\Customer\ModelHydrator;
use Magento\Framework\EntityManager\HydratorInterface;
use Magento\Framework\EntityManager\HydratorPool;
use PHPUnit\Framework\MockObject\Exception;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;

class ModelHydratorTest extends TestCase
{
/**
* @var CustomerFactory|MockObject
*/
private $customerFactory;

/**
* @var HydratorPool|MockObject
*/
private $hydratorPool;

/**
* @var HydratorInterface|MockObject
*/
private $hydrator;

/**
* @var ModelHydrator
*/
private $modelHydrator;

protected function setUp(): void
{
$this->customerFactory = $this->createMock(CustomerFactory::class);
$this->hydratorPool = $this->createMock(HydratorPool::class);
$this->hydrator = $this->createMock(HydratorInterface::class);

$this->modelHydrator = new ModelHydrator(
$this->customerFactory,
$this->hydratorPool
);
}

/**
* Test hydrate method with existing model
*
* @return void
* @throws Exception
*/

public function testHydrateWithExistingModel()
{
$customer = $this->createMock(Customer::class);
$resolverData = [
'model_id' => 1,
'model_entity_type' => 'customer',
'model_data' => ['id' => 1]
];

$this->customerFactory
->method('create')
->willReturn($customer);
$this->hydrator
->method('hydrate')
->with($customer, $resolverData['model_data'])
->willReturnSelf();
$this->hydratorPool
->method('getHydrator')
->with('customer')
->willReturn($this->hydrator);

$this->modelHydrator->hydrate($resolverData);
$this->modelHydrator->hydrate($resolverData);
$this->assertSame($customer, $resolverData['model']);
}

/**
* Test hydrate method with new model
*
* @return void
* @throws Exception
*/

public function testHydrateWithNewModel()
{
$customer = $this->createMock(Customer::class);
$resolverData = [
'model_id' => 1,
'model_entity_type' => 'customer',
'model_data' => ['id' => 1]
];

$this->customerFactory
->method('create')
->willReturn($customer);
$this->hydratorPool
->method('getHydrator')
->willReturn($this->hydrator);
$this->hydrator->expects($this->once())
->method('hydrate')
->with($customer, $resolverData['model_data']);

$this->modelHydrator->hydrate($resolverData);
$this->assertSame($customer, $resolverData['model']);
}

/**
* Test that resetState method resets the state of the modelHydrator
*
* @return void
* @throws Exception
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function testResetState()
{
$customer1 = $this->createMock(Customer::class);
$customer2 = $this->createMock(Customer::class);

$resolverData1 = [
'model_id' => 1,
'model_entity_type' => 'customer',
'model_data' => ['id' => 1]
];

$resolverData2 = [
'model_id' => 2,
'model_entity_type' => 'customer',
'model_data' => ['id' => 2]
];

$this->customerFactory
->method('create')
->willReturnOnConsecutiveCalls($customer1, $customer2);
$this->hydratorPool
->method('getHydrator')
->willReturn($this->hydrator);

$matcher = $this->exactly(2);
$expected1 = $resolverData1['model_data'];
$expected2 = $resolverData2['model_data'];

$this->hydrator
->expects($matcher)
->method('hydrate')
->willReturnCallback(function ($model, $data) use ($matcher, $expected1, $expected2) {
match ($matcher->numberOfInvocations()) {
1 => $this->assertEquals($expected1, $data),
2 => $this->assertEquals($expected2, $data),
};
});

$this->modelHydrator->hydrate($resolverData1);

$this->assertArrayHasKey('model', $resolverData1);
$this->assertEquals(1, $resolverData1['model_id']);

$this->modelHydrator->_resetState();

$this->modelHydrator->hydrate($resolverData2);

$this->assertArrayHasKey('model', $resolverData2);
$this->assertEquals(2, $resolverData2['model_id']);
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2016 Adobe
* All Rights Reserved.
*/
namespace Magento\Sales\Model\Order\Creditmemo\Sender;

@@ -104,7 +104,9 @@ public function send(

$transport = [
'order' => $order,
'order_id' => $order->getId(),
'creditmemo' => $creditmemo,
'creditmemo_id' => $creditmemo->getId(),
'comment' => $comment ? $comment->getComment() : '',
'billing' => $order->getBillingAddress(),
'payment_html' => $this->getPaymentHtml($order),
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2016 Adobe
* All Rights Reserved.
*/
declare(strict_types=1);

@@ -120,6 +120,10 @@ class EmailSenderTest extends TestCase
*/
private $senderBuilderFactoryMock;

private const CREDITMEMO_ID = 1;

private const ORDER_ID = 1;

/**
* @inheritDoc
*
@@ -154,9 +158,11 @@ protected function setUp(): void

$this->creditmemoMock = $this->getMockBuilder(\Magento\Sales\Model\Order\Creditmemo::class)
->disableOriginalConstructor()
->onlyMethods(['setEmailSent'])
->onlyMethods(['setEmailSent', 'getId'])
->addMethods(['setSendEmail'])
->getMock();
$this->creditmemoMock->method('getId')
->willReturn(self::CREDITMEMO_ID);

$this->commentMock = $this->getMockBuilder(CreditmemoCommentCreationInterface::class)
->disableOriginalConstructor()
@@ -192,6 +198,8 @@ protected function setUp(): void
$this->orderMock->expects($this->any())
->method('getPayment')
->willReturn($this->paymentInfoMock);
$this->orderMock->method('getId')
->willReturn(self::ORDER_ID);

$this->paymentHelperMock = $this->getMockBuilder(Data::class)
->disableOriginalConstructor()
@@ -285,7 +293,9 @@ public function testSend(
if (!$configValue || $forceSyncMode) {
$transport = [
'order' => $this->orderMock,
'order_id' => self::ORDER_ID,
'creditmemo' => $this->creditmemoMock,
'creditmemo_id' => self::CREDITMEMO_ID,
'comment' => $isComment ? 'Comment text' : '',
'billing' => $this->addressMock,
'payment_html' => 'Payment Info Block',
102 changes: 88 additions & 14 deletions app/code/Magento/SalesRule/Model/RulesApplier.php
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2014 Adobe
* All Rights Reserved.
*/

namespace Magento\SalesRule\Model;

use Magento\Framework\Event\ManagerInterface;
use Magento\Framework\Pricing\PriceCurrencyInterface;
use Magento\Quote\Model\Quote\Address;
use Magento\Quote\Model\Quote\Item\AbstractItem;
use Magento\SalesRule\Model\Data\RuleDiscount;
@@ -72,6 +74,11 @@ class RulesApplier
*/
private $discountAggregator;

/**
* @var PriceCurrencyInterface
*/
private $priceCurrency;

/**
* @param CalculatorFactory $calculatorFactory
* @param ManagerInterface $eventManager
@@ -81,6 +88,7 @@ class RulesApplier
* @param RuleDiscountInterfaceFactory|null $discountInterfaceFactory
* @param DiscountDataInterfaceFactory|null $discountDataInterfaceFactory
* @param SelectRuleCoupon|null $selectRuleCoupon
* @param PriceCurrencyInterface|null $priceCurrency
*/
public function __construct(
CalculatorFactory $calculatorFactory,
@@ -90,7 +98,8 @@ public function __construct(
?DataFactory $discountDataFactory = null,
?RuleDiscountInterfaceFactory $discountInterfaceFactory = null,
?DiscountDataInterfaceFactory $discountDataInterfaceFactory = null,
?SelectRuleCoupon $selectRuleCoupon = null
?SelectRuleCoupon $selectRuleCoupon = null,
?PriceCurrencyInterface $priceCurrency = null
) {
$this->calculatorFactory = $calculatorFactory;
$this->validatorUtility = $utility;
@@ -104,6 +113,7 @@ public function __construct(
?: ObjectManager::getInstance()->get(DiscountDataInterfaceFactory::class);
$this->selectRuleCoupon = $selectRuleCoupon
?: ObjectManager::getInstance()->get(SelectRuleCoupon::class);
$this->priceCurrency = $priceCurrency ?: ObjectManager::getInstance()->get(PriceCurrencyInterface::class);
}

/**
@@ -237,21 +247,28 @@ protected function applyRule($item, $rule, $address, array $couponCodes = [])
{
if ($item->getChildren() && $item->isChildrenCalculated()) {
$cloneItem = clone $item;

$applyToChildren = false;
foreach ($item->getChildren() as $childItem) {
if ($rule->getActions()->validate($childItem)) {
$discountData = $this->getDiscountData($childItem, $rule, $address, $couponCodes);
$this->setDiscountData($discountData, $childItem);
$applyToChildren = true;
}
}
/**
* validate without children
* Validates item without children to check whether the rule can be applied to the item itself
* If the rule can be applied to the item, the discount is applied to the item itself and
* distributed among its children
*/
if (!$applyToChildren && $rule->getActions()->validate($cloneItem)) {
if ($rule->getActions()->validate($cloneItem)) {
// Aggregate discount data from children
$discountData = $this->getDiscountDataFromChildren($item);
$this->setDiscountData($discountData, $item);
// Calculate discount data based on parent item
$discountData = $this->getDiscountData($item, $rule, $address, $couponCodes);
$this->distributeDiscount($discountData, $item);
// reset discount data in parent item after distributing discount to children
$discountData = $this->discountFactory->create();
$this->setDiscountData($discountData, $item);
} else {
foreach ($item->getChildren() as $childItem) {
if ($rule->getActions()->validate($childItem)) {
$discountData = $this->getDiscountData($childItem, $rule, $address, $couponCodes);
$this->setDiscountData($discountData, $childItem);
}
}
}
} else {
$discountData = $this->getDiscountData($item, $rule, $address, $couponCodes);
@@ -264,6 +281,63 @@ protected function applyRule($item, $rule, $address, array $couponCodes = [])
return $this;
}

/**
* Get discount data from children
*
* @param AbstractItem $item
* @return Data
*/
private function getDiscountDataFromChildren(AbstractItem $item): Data
{
$discountData = $this->discountFactory->create();

foreach ($item->getChildren() as $child) {
$discountData->setAmount($discountData->getAmount() + $child->getDiscountAmount());
$discountData->setBaseAmount($discountData->getBaseAmount() + $child->getBaseDiscountAmount());
$discountData->setOriginalAmount($discountData->getOriginalAmount() + $child->getOriginalDiscountAmount());
$discountData->setBaseOriginalAmount(
$discountData->getBaseOriginalAmount() + $child->getBaseOriginalDiscountAmount()
);
}

return $discountData;
}

/**
* Distributes discount applied from parent item to its children items
*
* This method originates from \Magento\SalesRule\Model\Quote\Discount::distributeDiscount()
*
* @param Data $discountData
* @param AbstractItem $item
* @see \Magento\SalesRule\Model\Quote\Discount::distributeDiscount()
*/
private function distributeDiscount(Data $discountData, AbstractItem $item): void
{
$data = [
'discount_amount' => $discountData->getAmount() - $item->getDiscountAmount(),
'base_discount_amount' => $discountData->getBaseAmount() - $item->getBaseDiscountAmount(),
];

$parentBaseRowTotal = max(0, $item->getBaseRowTotal() - $item->getBaseDiscountAmount());
$keys = array_keys($data);
$roundingDelta = [];
foreach ($keys as $key) {
//Initialize the rounding delta to a tiny number to avoid floating point precision problem
$roundingDelta[$key] = 0.0000001;
}
foreach ($item->getChildren() as $child) {
$childBaseRowTotalWithDiscount = max(0, $child->getBaseRowTotal() - $child->getBaseDiscountAmount());
$ratio = min(1, $parentBaseRowTotal != 0 ? $childBaseRowTotalWithDiscount / $parentBaseRowTotal : 0);
foreach ($keys as $key) {
$value = $data[$key] * $ratio;
$roundedValue = $this->priceCurrency->round($value + $roundingDelta[$key]);
$roundingDelta[$key] += $value - $roundedValue;
$child->setData($key, $child->getData($key) + $roundedValue);
}
}
}

/**
* Get discount Data
*
Original file line number Diff line number Diff line change
@@ -1,39 +1,40 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2015 Adobe
* All Rights Reserved.
*/
namespace Magento\Sales\Service\V1;

use Magento\Framework\Webapi\Rest\Request;
use Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection;
use Magento\TestFramework\Helper\Bootstrap;
use Magento\TestFramework\TestCase\WebapiAbstract;

/**
* Class CreditmemoEmailTest
* Test API call /creditmemo/{id}/emails
*/
class CreditmemoEmailTest extends WebapiAbstract
{
const SERVICE_VERSION = 'V1';
private const SERVICE_VERSION = 'V1';

const SERVICE_NAME = 'salesCreditmemoManagementV1';

const CREDITMEMO_INCREMENT_ID = '100000001';
private const SERVICE_NAME = 'salesCreditmemoManagementV1';

/**
* @magentoApiDataFixture Magento/Sales/_files/creditmemo_with_list.php
*/
public function testCreditmemoEmail()
{
$objectManager = \Magento\TestFramework\Helper\Bootstrap::getObjectManager();
$objectManager = Bootstrap::getObjectManager();

/** @var \Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection $creditmemoCollection */
/** @var Collection $creditmemoCollection */
$creditmemoCollection = $objectManager->get(
\Magento\Sales\Model\ResourceModel\Order\Creditmemo\Collection::class
Collection::class
);
$creditmemo = $creditmemoCollection->getFirstItem();
$serviceInfo = [
'rest' => [
'resourcePath' => '/V1/creditmemo/' . $creditmemo->getId() . '/emails',
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_POST,
'httpMethod' => Request::HTTP_METHOD_POST,
],
'soap' => [
'service' => self::SERVICE_NAME,
@@ -42,6 +43,7 @@ public function testCreditmemoEmail()
],
];
$requestData = ['id' => $creditmemo->getId()];
$this->_webApiCall($serviceInfo, $requestData);
$result = $this->_webApiCall($serviceInfo, $requestData);
$this->assertTrue($result);
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,39 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2019 Adobe
* All Rights Reserved.
*/

declare(strict_types=1);

namespace Magento\Sales\Model\Service;

use Magento\Bundle\Test\Fixture\AddProductToCart as AddBundleProductToCartFixture;
use Magento\Bundle\Test\Fixture\Option as BundleOptionFixture;
use Magento\Bundle\Test\Fixture\Product as BundleProductFixture;
use Magento\Catalog\Test\Fixture\Product as ProductFixture;
use Magento\Checkout\Test\Fixture\PlaceOrder as PlaceOrderFixture;
use Magento\Checkout\Test\Fixture\SetBillingAddress as SetBillingAddressFixture;
use Magento\Checkout\Test\Fixture\SetDeliveryMethod as SetDeliveryMethodFixture;
use Magento\Checkout\Test\Fixture\SetGuestEmail as SetGuestEmailFixture;
use Magento\Checkout\Test\Fixture\SetPaymentMethod as SetPaymentMethodFixture;
use Magento\Checkout\Test\Fixture\SetShippingAddress as SetShippingAddressFixture;
use Magento\Quote\Test\Fixture\GuestCart as GuestCartFixture;
use Magento\Sales\Api\Data\OrderInterface;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Magento\SalesRule\Model\Rule;
use Magento\SalesRule\Test\Fixture\ProductCondition as ProductConditionFixture;
use Magento\SalesRule\Test\Fixture\Rule as RuleFixture;
use Magento\TestFramework\Fixture\DataFixture;
use Magento\TestFramework\Fixture\DataFixtureStorage;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\TestFramework\Helper\Bootstrap;

/**
* Tests \Magento\Sales\Model\Service\InvoiceService
*
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
*/
class InvoiceServiceTest extends \PHPUnit\Framework\TestCase
{
@@ -21,12 +42,24 @@ class InvoiceServiceTest extends \PHPUnit\Framework\TestCase
*/
private $invoiceService;

/**
* @var OrderRepositoryInterface|null
*/
private ?OrderRepositoryInterface $orderRepository;

/**
* @var DataFixtureStorage|null
*/
private ?DataFixtureStorage $fixtures;

/**
* @inheritdoc
*/
protected function setUp(): void
{
$this->invoiceService = Bootstrap::getObjectManager()->create(InvoiceService::class);
$this->orderRepository = Bootstrap::getObjectManager()->create(OrderRepositoryInterface::class);
$this->fixtures = DataFixtureStorageManager::getStorage();
}

/**
@@ -40,6 +73,7 @@ public function testPrepareInvoiceConfigurableProduct(int $invoiceQty): void
/** @var OrderInterface $order */
$order = Bootstrap::getObjectManager()->create(Order::class)->load('100000001', 'increment_id');
$orderItems = $order->getItems();
$parentItemId = 0;
foreach ($orderItems as $orderItem) {
if ($orderItem->getParentItemId()) {
$parentItemId = $orderItem->getParentItemId();
@@ -164,6 +198,55 @@ public static function bundleProductQtyOrderedDataProvider(): array
];
}

#[
DataFixture(ProductFixture::class, ['price' => 10], as: 'p1'),
DataFixture(ProductFixture::class, ['price' => 20], as: 'p2'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$p1$']], 'opt1'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$p2$']], 'opt2'),
DataFixture(BundleProductFixture::class, ['_options' => ['$opt1$', '$opt2$']], 'bp1'),
DataFixture(ProductConditionFixture::class, ['attribute' => 'sku', 'value' => '$bp1.sku$'], 'cond1'),
DataFixture(
RuleFixture::class,
[
'simple_action' => Rule::BY_PERCENT_ACTION,
'discount_amount' => 20,
'actions' => ['$cond1$'],
'simple_free_shipping' => \Magento\OfflineShipping\Model\SalesRule\Rule::FREE_SHIPPING_ITEM
]
),
DataFixture(GuestCartFixture::class, as: 'cart'),
DataFixture(
AddBundleProductToCartFixture::class,
[
'cart_id' => '$cart.id$',
'product_id' => '$bp1.id$',
'selections' => [['$p1.id$'], ['$p2.id$']],
'qty' => 1
],
),
DataFixture(SetBillingAddressFixture::class, ['cart_id' => '$cart.id$']),
DataFixture(SetShippingAddressFixture::class, ['cart_id' => '$cart.id$']),
DataFixture(SetGuestEmailFixture::class, ['cart_id' => '$cart.id$']),
DataFixture(SetDeliveryMethodFixture::class, ['cart_id' => '$cart.id$']),
DataFixture(SetPaymentMethodFixture::class, ['cart_id' => '$cart.id$']),
DataFixture(PlaceOrderFixture::class, ['cart_id' => '$cart.id$'], 'order'),
]
public function testPrepareInvoiceBundleProductDynamicPriceWithDiscount(): void
{
$order = $this->fixtures->get('order');
$order = $this->orderRepository->get($order->getId());
$qtyToInvoice = [];
foreach ($order->getAllItems() as $item) {
if (!$item->getParentItemId()) {
$qtyToInvoice[$item->getId()] = 1;
}
}
$this->assertNotEmpty($qtyToInvoice);
$invoice = $this->invoiceService->prepareInvoice($order, $qtyToInvoice);
$this->assertEquals(-6, $invoice->getBaseDiscountAmount());
$this->assertEquals(24, $invoice->getBaseGrandTotal());
}

/**
* Associate product qty to invoice to order item id.
*
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<?php
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
* Copyright 2020 Adobe
* All Rights Reserved.
*/

declare(strict_types=1);

namespace Magento\SalesRule\Model\Quote;
@@ -20,7 +21,6 @@
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\ObjectManagerInterface;
use Magento\Quote\Api\CartRepositoryInterface;
use Magento\Quote\Model\Quote;
use Magento\Quote\Model\Quote\Address\Total;
use Magento\Quote\Model\Quote\Address\Total\Subtotal;
use Magento\Quote\Model\Quote\Item;
@@ -39,6 +39,7 @@
use Magento\TestFramework\Fixture\DataFixtureStorage;
use Magento\TestFramework\Fixture\DataFixtureStorageManager;
use Magento\TestFramework\Helper\Bootstrap;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

/**
@@ -103,7 +104,7 @@ protected function setUp(): void
parent::setUp();
$this->objectManager = Bootstrap::getObjectManager();
$this->criteriaBuilder = $this->objectManager->get(SearchCriteriaBuilder::class);
$this->quoteRepository = $this->objectManager->get(CartRepositoryInterface::class);
$this->quoteRepository = $this->objectManager->create(CartRepositoryInterface::class);
$this->fixtures = DataFixtureStorageManager::getStorage();
$this->discountCollector = $this->objectManager->create(Discount::class);
$this->subtotalCollector = $this->objectManager->create(Subtotal::class);
@@ -113,29 +114,27 @@ protected function setUp(): void
$this->total = $this->objectManager->create(Total::class);
}

/**
* @magentoDataFixture Magento/Checkout/_files/quote_with_bundle_product_with_dynamic_price.php
* @dataProvider bundleProductWithDynamicPriceAndCartPriceRuleDataProvider
* @param string $coupon
* @param array $discounts
* @param float $totalDiscount
* @return void
*/
#[
DataProvider('bundleProductWithDynamicPriceAndCartPriceRuleDataProvider'),
AppIsolation(true),
DataFixture(ProductFixture::class, ['price' => 10, 'special_price' => 5.99], as: 'simple1'),
DataFixture(ProductFixture::class, ['price' => 20, 'special_price' => 15.99], as: 'simple2'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple1$']], 'opt1'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple2$']], 'opt2'),
DataFixture(BundleProductFixture::class, ['_options' => ['$opt1$', '$opt2$']], 'bundle'),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => 'bundle_product_with_dynamic_price'],
['attribute' => 'sku', 'value' => '$bundle.sku$'],
'cond1'
),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => 'simple1'],
['attribute' => 'sku', 'value' => '$simple1.sku$'],
'cond2'
),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => 'simple2'],
['attribute' => 'sku', 'value' => '$simple2.sku$'],
'cond3'
),
DataFixture(
@@ -153,32 +152,200 @@ protected function setUp(): void
['coupon_code' => 'simple2_cc', 'discount_amount' => 50, 'actions' => ['$cond3$']],
'rule3'
),
DataFixture(GuestCartFixture::class, as: 'cart'),
DataFixture(
AddBundleProductToCart::class,
[
'cart_id' => '$cart.id$',
'product_id' => '$bundle.id$',
'selections' => [['$simple1.id$'], ['$simple2.id$']],
'qty' => 1
],
)
]
public function testBundleProductWithDynamicPriceAndCartPriceRule(
string $coupon,
array $discounts,
float $totalDiscount
): void {
$quote = $this->getQuote('quote_with_bundle_product_with_dynamic_price');
$quote = $this->quoteRepository->get($this->fixtures->get('cart')->getId());
$quote->setCouponCode($coupon);
$quote->collectTotals();
$this->quoteRepository->save($quote);
$this->assertEquals(21.98, $quote->getBaseSubtotal());
$this->assertEquals($totalDiscount, $quote->getShippingAddress()->getDiscountAmount());
$actual = [];
$fixtures = [];
$items = $quote->getAllItems();
$this->assertCount(3, $items);
/** @var Item $item*/
$item = array_shift($items);
$this->assertEquals('bundle_product_with_dynamic_price-simple1-simple2', $item->getSku());
$this->assertEquals($discounts[$item->getSku()], $item->getDiscountAmount());
$item = array_shift($items);
$this->assertEquals('simple1', $item->getSku());
$this->assertEquals(5.99, $item->getPrice());
$this->assertEquals($discounts[$item->getSku()], $item->getDiscountAmount());
$item = array_shift($items);
$this->assertEquals('simple2', $item->getSku());
$this->assertEquals(15.99, $item->getPrice());
$this->assertEquals($discounts[$item->getSku()], $item->getDiscountAmount());
foreach (array_keys($discounts) as $fixture) {
$fixtures[$this->fixtures->get($fixture)->getId()] = $fixture;
}
foreach ($quote->getAllItems() as $item) {
$actual[$fixtures[$item->getProductId()]] = $item->getDiscountAmount();
}
$this->assertEquals($discounts, $actual);
}

#[
AppIsolation(true),
DataFixture(ProductFixture::class, ['price' => 10, 'special_price' => 5.99], as: 'simple1'),
DataFixture(ProductFixture::class, ['price' => 20, 'special_price' => 15.99], as: 'simple2'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple1$']], 'opt1'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple2$']], 'opt2'),
DataFixture(BundleProductFixture::class, ['_options' => ['$opt1$', '$opt2$']], 'bundle'),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => '$bundle.sku$'],
'cond1'
),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => '$simple1.sku$'],
'cond2'
),
DataFixture(
RuleFixture::class,
[
'simple_action' => Rule::BY_PERCENT_ACTION,
'discount_amount' => 20,
'actions' => ['$cond1$'],
'stop_rules_processing' => false,
'sort_order' => 1,
],
'rule1'
),
DataFixture(
RuleFixture::class,
[
'simple_action' => Rule::BY_FIXED_ACTION,
'discount_amount' => 1.5,
'actions' => ['$cond2$'],
'stop_rules_processing' => false,
'sort_order' => 2,
],
'rule2'
),
DataFixture(GuestCartFixture::class, as: 'cart'),
DataFixture(
AddBundleProductToCart::class,
[
'cart_id' => '$cart.id$',
'product_id' => '$bundle.id$',
'selections' => [['$simple1.id$'], ['$simple2.id$']],
'qty' => 1
],
)
]
public function testBundleProductDynamicPriceWithBundleDiscountAndChildDiscount(): void
{
$discounts = [
// bundle with dynamic price does not have discount on its own, instead it's distributed to children
'bundle' => 0,
// rule1 = (20/100 * 21.98) * (5.99 / 21.98) = 1.198
// rule2 = 1.50
// D = rule1 + rule1 = 1.198 + 1.50 = 2.698 ~ 2.70
'simple1' => 2.70,
// rule1 = (20/100 * 21.98) * (15.99 / 21.98) = 3.198
// D = rule1 = 3.198 ~ 3.20
'simple2' => 3.20,
];
$quote = $this->quoteRepository->get($this->fixtures->get('cart')->getId());
$this->assertEquals(21.98, $quote->getBaseSubtotal());
$this->assertEquals(-5.90, $quote->getShippingAddress()->getDiscountAmount());
$actual = [];
$fixtures = [];
$items = $quote->getAllItems();
$this->assertCount(3, $items);
/** @var Item $item*/
foreach (array_keys($discounts) as $fixture) {
$fixtures[$this->fixtures->get($fixture)->getId()] = $fixture;
}
foreach ($quote->getAllItems() as $item) {
$actual[$fixtures[$item->getProductId()]] = $item->getDiscountAmount();
}
$this->assertEquals($discounts, $actual);
}

#[
AppIsolation(true),
DataFixture(ProductFixture::class, ['price' => 10, 'special_price' => 5.99], as: 'simple1'),
DataFixture(ProductFixture::class, ['price' => 20, 'special_price' => 15.99], as: 'simple2'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple1$']], 'opt1'),
DataFixture(BundleOptionFixture::class, ['product_links' => ['$simple2$']], 'opt2'),
DataFixture(BundleProductFixture::class, ['_options' => ['$opt1$', '$opt2$']], 'bundle'),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => '$bundle.sku$'],
'cond1'
),
DataFixture(
ProductConditionFixture::class,
['attribute' => 'sku', 'value' => '$simple1.sku$'],
'cond2'
),
DataFixture(
RuleFixture::class,
[
'simple_action' => Rule::BY_PERCENT_ACTION,
'discount_amount' => 20,
'actions' => ['$cond1$'],
'stop_rules_processing' => false,
'sort_order' => 2,
],
'rule1'
),
DataFixture(
RuleFixture::class,
[
'simple_action' => Rule::BY_FIXED_ACTION,
'discount_amount' => 1.5,
'actions' => ['$cond2$'],
'stop_rules_processing' => false,
'sort_order' => 1,
],
'rule2'
),
DataFixture(GuestCartFixture::class, as: 'cart'),
DataFixture(
AddBundleProductToCart::class,
[
'cart_id' => '$cart.id$',
'product_id' => '$bundle.id$',
'selections' => [['$simple1.id$'], ['$simple2.id$']],
'qty' => 1
],
)
]
public function testBundleProductDynamicPriceWithChildDiscountAndBundleDiscount(): void
{
$discounts = [
// bundle with dynamic price does not have discount on its own, instead it's distributed to children
'bundle' => 0,
// rule2 = 1.50
// rule1 = (20/100 * (21.98 - 1.50) * ((5.99 - 1.50) / (21.98 - 1.50)) = 0.898
// D = rule2 + rule1 = 1.50 + 0.898 = 2.398 ~ 2.40
'simple1' => 2.40,
// rule1 = (20/100 * (21.98 - 1.50) * (15.99 / (21.98 - 1.50)) = 3.198
// D = rule1 = 3.198 ~ 3.20
'simple2' => 3.20,
];
$quote = $this->quoteRepository->get($this->fixtures->get('cart')->getId());
$this->assertEquals(21.98, $quote->getBaseSubtotal());
$this->assertEquals(-5.60, $quote->getShippingAddress()->getDiscountAmount());
$actual = [];
$fixtures = [];
$items = $quote->getAllItems();
$this->assertCount(3, $items);
/** @var Item $item*/
foreach (array_keys($discounts) as $fixture) {
$fixtures[$this->fixtures->get($fixture)->getId()] = $fixture;
}
foreach ($quote->getAllItems() as $item) {
$actual[$fixtures[$item->getProductId()]] = $item->getDiscountAmount();
}
$this->assertEquals($discounts, $actual);
}

/**
@@ -190,16 +357,17 @@ public static function bundleProductWithDynamicPriceAndCartPriceRuleDataProvider
[
'bundle_cc',
[
'bundle_product_with_dynamic_price-simple1-simple2' => 10.99,
'simple1' => 0,
'simple2' => 0,
// bundle with dynamic price does not have discount on its own, instead it's distributed to children
'bundle' => 0,
'simple1' => 3,
'simple2' => 7.99,
],
-10.99
],
[
'simple1_cc',
[
'bundle_product_with_dynamic_price-simple1-simple2' => 0,
'bundle' => 0,
'simple1' => 3,
'simple2' => 0,
],
@@ -208,7 +376,7 @@ public static function bundleProductWithDynamicPriceAndCartPriceRuleDataProvider
[
'simple2_cc',
[
'bundle_product_with_dynamic_price-simple1-simple2' => 0,
'bundle' => 0,
'simple1' => 0,
'simple2' => 8,
],
@@ -217,19 +385,6 @@ public static function bundleProductWithDynamicPriceAndCartPriceRuleDataProvider
];
}

/**
* @param string $reservedOrderId
* @return Quote
*/
private function getQuote(string $reservedOrderId): Quote
{
$searchCriteria = $this->criteriaBuilder->addFilter('reserved_order_id', $reservedOrderId)
->create();
$carts = $this->quoteRepository->getList($searchCriteria)
->getItems();
return array_shift($carts);
}

/**
* @return void
* @throws NoSuchEntityException

0 comments on commit 8498d19

Please sign in to comment.