Загрузка данных


Subject: [PATCH] ~
---
Index: src/Controller/Front/CartApiController.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/Controller/Front/CartApiController.php b/src/Controller/Front/CartApiController.php
--- a/src/Controller/Front/CartApiController.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/src/Controller/Front/CartApiController.php	(date 1784821023869)
@@ -10,6 +10,7 @@
 use App\Repository\CartProductRepository;
 use App\Repository\CartRepository;
 use App\Repository\ProductRepository;
+use App\Utils\Generator\TokenGenerator;
 use Doctrine\Persistence\ManagerRegistry as Doctrine;
 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -48,7 +49,11 @@
         $cart = $cartRepository->findOneBy(['token' => $cartToken]);
         if (!$cart) {
             $cart = new Cart();
-            $cart->setToken($cartToken);
+            $cart->setToken(
+                is_string($cartToken) && preg_match('/\A[0-9a-f]{32}\z/', $cartToken)
+                    ? $cartToken
+                    : TokenGenerator::generateToken()
+            );
         }
 
         /** @var CartProduct|null $cartProduct */
Index: tests/Functional/ApiPlatform/CartProductResourceTest.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Functional/ApiPlatform/CartProductResourceTest.php b/tests/Functional/ApiPlatform/CartProductResourceTest.php
--- a/tests/Functional/ApiPlatform/CartProductResourceTest.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/tests/Functional/ApiPlatform/CartProductResourceTest.php	(date 1784821053104)
@@ -10,6 +10,7 @@
 use App\Entity\User;
 use App\Repository\UserRepository;
 use App\Tests\TestUtils\Fixtures\UserFixtures;
+use App\Utils\Generator\TokenGenerator;
 use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Group;
@@ -317,6 +318,24 @@
         $this->assertContextLinesUnchanged($context);
     }
 
+    public function testDuplicateCartProductReturnsConflictWithoutChangingState(): void
+    {
+        $client = self::createClient();
+        $context = $this->createCartContext();
+        $this->setCartToken($client, $context['tokenA']);
+        $countBefore = $this->countCartProducts();
+
+        $this->requestPost($client, [
+            'cart' => $this->cartIri($context['cartA']),
+            'product' => $this->productIri($context['productA']),
+            'quantity' => 7,
+        ]);
+
+        self::assertResponseStatusCodeSame(Response::HTTP_CONFLICT);
+        self::assertSame($countBefore, $this->countCartProducts());
+        $this->assertContextLinesUnchanged($context);
+    }
+
     #[DataProvider('invalidPostSemanticQuantities')]
     public function testPostRejectsSemanticInvalidQuantity(mixed $quantity, string $message): void
     {
@@ -543,8 +562,8 @@
     {
         $entityManager = self::getContainer()->get(EntityManagerInterface::class);
         $suffix = str_replace('.', '', uniqid('', true));
-        $tokenA = 'cart-a-'.$suffix;
-        $tokenB = 'cart-b-'.$suffix;
+        $tokenA = TokenGenerator::generateToken();
+        $tokenB = TokenGenerator::generateToken();
         $productA = (new Product())
             ->setTitle('Cart product A '.$suffix)
             ->setPrice('10.00')
Index: tests/Integration/Utils/Manager/OrderManagerMoneyTest.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Integration/Utils/Manager/OrderManagerMoneyTest.php b/tests/Integration/Utils/Manager/OrderManagerMoneyTest.php
--- a/tests/Integration/Utils/Manager/OrderManagerMoneyTest.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/tests/Integration/Utils/Manager/OrderManagerMoneyTest.php	(date 1784821058201)
@@ -12,6 +12,7 @@
 use App\Entity\User;
 use App\Entity\StaticStorage\OrderStaticStorage;
 use App\Utils\Manager\OrderManager;
+use App\Utils\Generator\TokenGenerator;
 use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\Attributes\Group;
 use PHPUnit\Framework\Attributes\TestWith;
@@ -54,7 +55,7 @@
             ->setEmail('order-money-'.uniqid('', true).'@example.test')
             ->setPassword('not-used-by-this-test')
             ->setIsVerified(true);
-        $cart = new Cart();
+        $cart = (new Cart())->setToken(TokenGenerator::generateToken());
 
         $this->entityManager->persist($user);
 
Index: tests/Functional/ApiPlatform/OrderCheckoutResourceTest.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Functional/ApiPlatform/OrderCheckoutResourceTest.php b/tests/Functional/ApiPlatform/OrderCheckoutResourceTest.php
--- a/tests/Functional/ApiPlatform/OrderCheckoutResourceTest.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/tests/Functional/ApiPlatform/OrderCheckoutResourceTest.php	(date 1784821054859)
@@ -14,6 +14,7 @@
 use App\Repository\UserRepository;
 use App\Tests\TestUtils\Fixtures\UserFixtures;
 use App\Utils\Money\DecimalMoney;
+use App\Utils\Generator\TokenGenerator;
 use Doctrine\ORM\EntityManagerInterface;
 use PHPUnit\Framework\Attributes\DataProvider;
 use PHPUnit\Framework\Attributes\Group;
@@ -379,7 +380,7 @@
     {
         $entityManager = $this->getEntityManager();
         $suffix = str_replace('.', '', uniqid('', true));
-        $token = 'checkout-'.$suffix;
+        $token = TokenGenerator::generateToken();
         $cart = (new Cart())->setToken($token);
         $lines = [];
 
Index: migrations/Version20260723000000.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/migrations/Version20260723000000.php b/migrations/Version20260723000000.php
new file mode 100644
--- /dev/null	(date 1784821059908)
+++ b/migrations/Version20260723000000.php	(date 1784821059908)
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+namespace DoctrineMigrations;
+
+use Doctrine\DBAL\Schema\Schema;
+use Doctrine\Migrations\AbstractMigration;
+
+final class Version20260723000000 extends AbstractMigration
+{
+    public function getDescription(): string
+    {
+        return 'Enforce cart identity and cart product uniqueness';
+    }
+
+    public function up(Schema $schema): void
+    {
+        $this->addSql('ALTER TABLE cart ALTER COLUMN token TYPE VARCHAR(32)');
+        $this->addSql('ALTER TABLE cart ALTER COLUMN token SET NOT NULL');
+        $this->addSql('ALTER TABLE cart ADD CONSTRAINT UNIQ_CART_TOKEN UNIQUE (token)');
+        $this->addSql('ALTER TABLE cart_product ADD CONSTRAINT UNIQ_CART_PRODUCT_CART_PRODUCT UNIQUE (cart_id, product_id)');
+    }
+
+    public function down(Schema $schema): void
+    {
+        $this->addSql('ALTER TABLE cart_product DROP CONSTRAINT UNIQ_CART_PRODUCT_CART_PRODUCT');
+        $this->addSql('ALTER TABLE cart DROP CONSTRAINT UNIQ_CART_TOKEN');
+        $this->addSql('ALTER TABLE cart ALTER COLUMN token DROP NOT NULL');
+        $this->addSql('ALTER TABLE cart ALTER COLUMN token TYPE VARCHAR(255)');
+    }
+}
Index: src/Utils/ApiPlatform/Event/Subscriber/SetCartTokenSubscriber.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/Utils/ApiPlatform/Event/Subscriber/SetCartTokenSubscriber.php b/src/Utils/ApiPlatform/Event/Subscriber/SetCartTokenSubscriber.php
--- a/src/Utils/ApiPlatform/Event/Subscriber/SetCartTokenSubscriber.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/src/Utils/ApiPlatform/Event/Subscriber/SetCartTokenSubscriber.php	(date 1784821022179)
@@ -38,7 +38,7 @@
 
         $cartToken = $event->getRequest()->cookies->get('CART_TOKEN');
 
-        if (!$cartToken) {
+        if (!is_string($cartToken) || !preg_match('/\A[0-9a-f]{32}\z/', $cartToken)) {
             $cartToken = TokenGenerator::generateToken();
         }
 
Index: tests/Integration/Doctrine/CommerceAggregateLifecycleTest.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Integration/Doctrine/CommerceAggregateLifecycleTest.php b/tests/Integration/Doctrine/CommerceAggregateLifecycleTest.php
--- a/tests/Integration/Doctrine/CommerceAggregateLifecycleTest.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/tests/Integration/Doctrine/CommerceAggregateLifecycleTest.php	(date 1784821056559)
@@ -10,6 +10,7 @@
 use App\Entity\OrderProduct;
 use App\Entity\Product;
 use App\Entity\User;
+use App\Utils\Generator\TokenGenerator;
 use Doctrine\DBAL\Configuration;
 use Doctrine\DBAL\Connection;
 use Doctrine\DBAL\Exception\ForeignKeyConstraintViolationException;
@@ -57,7 +58,7 @@
     public function testCartRootPersistsAndOrphanRemovesItsLines(): void
     {
         [, $product] = $this->persistUserAndProduct();
-        $cart = new Cart();
+        $cart = (new Cart())->setToken(TokenGenerator::generateToken());
         $cartProduct = $this->newCartProduct($product);
         $cart->addCartProduct($cartProduct);
 
@@ -97,7 +98,7 @@
     public function testRemovingCartRemovesItsLinesButKeepsProduct(): void
     {
         [, $product] = $this->persistUserAndProduct();
-        $cart = new Cart();
+        $cart = (new Cart())->setToken(TokenGenerator::generateToken());
         $cart->addCartProduct($this->newCartProduct($product));
         $this->entityManager->persist($cart);
         $this->entityManager->flush();
@@ -279,7 +280,10 @@
     private function insertRawCartProduct(Connection $connection): array
     {
         [, $productId] = $this->insertRawUserAndProduct($connection);
-        $connection->executeStatement('INSERT INTO cart (token, created_at) VALUES (NULL, CURRENT_TIMESTAMP)');
+        $connection->executeStatement(
+            'INSERT INTO cart (token, created_at) VALUES (?, CURRENT_TIMESTAMP)',
+            [TokenGenerator::generateToken()],
+        );
         $cartId = (int) $connection->lastInsertId();
         $connection->executeStatement(
             'INSERT INTO cart_product (cart_id, product_id, quantity) VALUES (?, ?, 1)',
Index: tests/Functional/ApiPlatform/CartResourceTest.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/tests/Functional/ApiPlatform/CartResourceTest.php b/tests/Functional/ApiPlatform/CartResourceTest.php
new file mode 100644
--- /dev/null	(date 1784821607791)
+++ b/tests/Functional/ApiPlatform/CartResourceTest.php	(date 1784821607791)
@@ -0,0 +1,76 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Functional\ApiPlatform;
+
+use App\Entity\Cart;
+use App\Utils\Generator\TokenGenerator;
+use Doctrine\ORM\EntityManagerInterface;
+use PHPUnit\Framework\Attributes\Group;
+use Symfony\Component\BrowserKit\Cookie;
+use Symfony\Component\HttpFoundation\Response;
+
+#[Group(name: 'functional')]
+class CartResourceTest extends ResourceTestUtils
+{
+    public function testCartPostGeneratesAValidTokenWhenOwnershipCookieIsMissing(): void
+    {
+        $client = self::createClient();
+        $client->request(
+            'POST',
+            '/api/carts',
+            [],
+            [],
+            self::REQUEST_HEADERS,
+            '{}',
+        );
+
+        self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
+        $document = $this->getResponseDecodedContent($client);
+        self::assertIsString($document['token']);
+        self::assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $document['token']);
+    }
+
+    public function testCartPostGeneratesAValidTokenForAnInvalidOwnershipCookie(): void
+    {
+        $client = self::createClient();
+        $client->getCookieJar()->set(new Cookie('CART_TOKEN', 'wrong-token'));
+        $client->request(
+            'POST',
+            '/api/carts',
+            [],
+            [],
+            self::REQUEST_HEADERS,
+            '{}',
+        );
+
+        self::assertResponseStatusCodeSame(Response::HTTP_CREATED);
+        $document = $this->getResponseDecodedContent($client);
+        self::assertIsString($document['token']);
+        self::assertMatchesRegularExpression('/\A[0-9a-f]{32}\z/', $document['token']);
+    }
+
+    public function testCartPostDuplicateTokenReturnsConflict(): void
+    {
+        $token = TokenGenerator::generateToken();
+        $client = self::createClient();
+        $entityManager = self::getContainer()->get(EntityManagerInterface::class);
+        $cart = (new Cart())->setToken($token);
+        $entityManager->persist($cart);
+        $entityManager->flush();
+
+        $client->getCookieJar()->set(new Cookie('CART_TOKEN', $token));
+        $client->request(
+            'POST',
+            '/api/carts',
+            [],
+            [],
+            self::REQUEST_HEADERS,
+            '{}',
+        );
+
+        self::assertResponseStatusCodeSame(Response::HTTP_CONFLICT);
+        self::assertSame(1, $entityManager->getRepository(Cart::class)->count(['token' => $token]));
+    }
+}
Index: src/Entity/Cart.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/Entity/Cart.php b/src/Entity/Cart.php
--- a/src/Entity/Cart.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/src/Entity/Cart.php	(date 1784821556318)
@@ -13,6 +13,7 @@
 use DateTimeImmutable;
 use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\Common\Collections\Collection;
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
 use Doctrine\DBAL\Types\Types;
 use Doctrine\ORM\Mapping\Column;
 use Doctrine\ORM\Mapping\Entity;
@@ -20,13 +21,16 @@
 use Doctrine\ORM\Mapping\Id;
 use Doctrine\ORM\Mapping\OneToMany;
 use Doctrine\ORM\Mapping\Table;
+use Doctrine\ORM\Mapping\UniqueConstraint;
 use Symfony\Component\Serializer\Annotation\Groups;
 
 #[
     Table(name: '`cart`'),
     Entity(repositoryClass: CartRepository::class)
 ]
+#[UniqueConstraint(name: 'UNIQ_CART_TOKEN', columns: ['token'])]
 #[ApiResource(
+    exceptionToStatus: [UniqueConstraintViolationException::class => 409],
     operations: [
         new GetCollection(
             normalizationContext: ['groups' => ['cart:list']],
@@ -55,7 +59,7 @@
     #[Groups(['cart:list', 'cart:item'])]
     protected ?int $id;
 
-    #[Column(type: Types::STRING, length: 255, nullable: true)]
+    #[Column(type: Types::STRING, length: 32, nullable: false)]
     #[Groups(['cart:list', 'cart:item', 'cart:list:write'])]
     protected ?string $token;
 
Index: src/Entity/CartProduct.php
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/Entity/CartProduct.php b/src/Entity/CartProduct.php
--- a/src/Entity/CartProduct.php	(revision 2252d8f735414967db11cf5f14e017509dce76c8)
+++ b/src/Entity/CartProduct.php	(date 1784821557966)
@@ -11,6 +11,7 @@
 use ApiPlatform\Metadata\Patch;
 use ApiPlatform\Metadata\Post;
 use App\Repository\CartProductRepository;
+use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
 use Doctrine\DBAL\Types\Types;
 use Doctrine\ORM\Mapping\Column;
 use Doctrine\ORM\Mapping\Entity;
@@ -19,6 +20,7 @@
 use Doctrine\ORM\Mapping\JoinColumn;
 use Doctrine\ORM\Mapping\ManyToOne;
 use Doctrine\ORM\Mapping\Table;
+use Doctrine\ORM\Mapping\UniqueConstraint;
 use Symfony\Component\Serializer\Annotation\Groups;
 use Symfony\Component\Validator\Constraints as Assert;
 
@@ -26,39 +28,42 @@
     Table(name: '`cart_product`'),
     Entity(repositoryClass: CartProductRepository::class)
 ]
-#[ApiResource(operations: [
-    new GetCollection(
-        normalizationContext: ['groups' => ['cart_product:list']],
-        name: 'api_cart_products_get_collection'
-    ),
-    new Post(
-        normalizationContext: ['groups' => ['cart_product:list:write']],
-        denormalizationContext: [
-            'groups' => ['cart_product:create'],
-            'allow_extra_attributes' => false,
-        ],
-        securityPostDenormalize: "is_granted('CART_PRODUCT_EDIT', object)",
-        name: 'api_cart_products_post_collection'
-    ),
-    new Get(
-        normalizationContext: ['groups' => ['cart_product:item']],
-        security: "is_granted('CART_PRODUCT_READ', object)",
-        name: 'api_cart_products_get_item'
-    ),
-    new Delete(
-        security: "is_granted('CART_PRODUCT_DELETE', object)",
-        name: 'api_cart_products_delete_item'
-    ),
-    new Patch(
-        inputFormats: ['json' => ['application/merge-patch+json']],
-        denormalizationContext: [
-            'groups' => ['cart_product:update'],
-            'allow_extra_attributes' => false,
-        ],
-        security: "is_granted('CART_PRODUCT_EDIT', object)",
-        name: 'api_cart_products_patch_item'
-    ),
-])]
+#[UniqueConstraint(name: 'UNIQ_CART_PRODUCT_CART_PRODUCT', columns: ['cart_id', 'product_id'])]
+#[ApiResource(
+    exceptionToStatus: [UniqueConstraintViolationException::class => 409],
+    operations: [
+        new GetCollection(
+            normalizationContext: ['groups' => ['cart_product:list']],
+            name: 'api_cart_products_get_collection'
+        ),
+        new Post(
+            normalizationContext: ['groups' => ['cart_product:list:write']],
+            denormalizationContext: [
+                'groups' => ['cart_product:create'],
+                'allow_extra_attributes' => false,
+            ],
+            securityPostDenormalize: "is_granted('CART_PRODUCT_EDIT', object)",
+            name: 'api_cart_products_post_collection'
+        ),
+        new Get(
+            normalizationContext: ['groups' => ['cart_product:item']],
+            security: "is_granted('CART_PRODUCT_READ', object)",
+            name: 'api_cart_products_get_item'
+        ),
+        new Delete(
+            security: "is_granted('CART_PRODUCT_DELETE', object)",
+            name: 'api_cart_products_delete_item'
+        ),
+        new Patch(
+            inputFormats: ['json' => ['application/merge-patch+json']],
+            denormalizationContext: [
+                'groups' => ['cart_product:update'],
+                'allow_extra_attributes' => false,
+            ],
+            security: "is_granted('CART_PRODUCT_EDIT', object)",
+            name: 'api_cart_products_patch_item'
+        ),
+    ])]
 class CartProduct
 {
     #[Id, GeneratedValue, Column(type: Types::INTEGER)]