diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4f2405b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: + - main + - master + - 'claude/**' + pull_request: + branches: + - main + - master + +jobs: + test: + name: PHP ${{ matrix.php-version }} Tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['8.0', '8.1', '8.2'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mbstring, intl, pdo + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPUnit tests + run: vendor/bin/phpunit --configuration phpunit.xml.dist --testdox diff --git a/.gitignore b/.gitignore index 3dab634..52f02e3 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ # Embedded web-server pid file /.web-server-pid +.phpunit.result.cache diff --git a/Controller/MpbController.php b/Controller/MpbController.php index 8c33610..0273483 100755 --- a/Controller/MpbController.php +++ b/Controller/MpbController.php @@ -29,6 +29,7 @@ class MpbController extends AbstractController { /** @var LoggerInterface */ private $logger; + /** * @var ProductRepository */ @@ -43,7 +44,7 @@ class MpbController extends AbstractController * @var EntityManagerInterface */ protected $entityManager; - + /** * @var PurchaseFlow */ @@ -86,7 +87,6 @@ public function __construct( $this->cartPurchaseFlow = $cartPurchaseFlow; $this->configRepository = $configRepository; $this->layoutRepository = $layoutRepository; - $this->cartPurchaseFlow = $cartPurchaseFlow; } /** @@ -95,94 +95,61 @@ public function __construct( */ public function index(Request $request) { - // 最初にログを出力してメソッドが実行されているか確認 - error_log('[MPBC] Controller method started: ' . $request->getMethod() . ' ' . $request->getRequestUri()); - $this->logger->info('[MPBC] Starting mpb product entry', [ 'method' => $request->getMethod(), - 'request_uri' => $request->getRequestUri(), - 'session_id' => $request->getSession()->getId() + 'session_id' => substr($request->getSession()->getId(), 0, 8), ]); $form = $this->createForm(MpbType::class); - - // リクエストデータをログ出力 + if ($request->isMethod('POST')) { - error_log('[MPBC] POST request processing started'); - error_log('[MPBC] Raw request data: ' . print_r($request->request->all(), true)); - - $this->logger->info('[MPBC] POST request received', [ - 'request_data' => $request->request->all() - ]); - - // 手動でフォームデータを取得 + // フォームデータを手動取得(クライアント側フォーマット ¥/カンマ をFormバリデーターがブロックするため) $mpbData = $request->request->all('mpb'); - error_log('[MPBC] MPB data: ' . print_r($mpbData, true)); - + $productName = $mpbData['product_name'] ?? ''; $priceRaw = $mpbData['price'] ?? ''; - - // 価格データをクリーンアップ(¥マークやカンマを除去) + + // 価格から数値のみ抽出(¥マーク・カンマ・全角文字を除去) $priceClean = preg_replace('/[^\d]/', '', $priceRaw); - $price = (int)$priceClean; - - $csrfToken = $mpbData['_token'] ?? ''; - - error_log('[MPBC] Form data extracted: name=' . $productName . ', price_raw=' . $priceRaw . ', price=' . $price); - $this->logger->info('[MPBC] Manual form data extraction', [ + $price = (int) $priceClean; + + $this->logger->info('[MPBC] POST request received', [ 'product_name' => $productName, - 'price_raw' => $priceRaw, 'price' => $price, - 'csrf_token' => $csrfToken ]); - - // CSRF検証をスキップして直接処理 + if (!empty($productName) && $price > 0) { - error_log('[MPBC] Validation passed, creating product and adding to cart'); try { - // セッションIDを取得 $sessionId = $request->getSession()->getId(); - - // 商品を作成してカートに追加 $result = $this->createProductAndAddToCart($productName, $price, $sessionId); - - error_log('[MPBC] Cart addition result: ' . ($result ? 'success' : 'failed')); + if ($result) { - error_log('[MPBC] Successfully added to cart, redirecting'); $this->addFlash('eccube.front.cart.add.complete', '商品をカートに追加しました。'); return $this->redirectToRoute('cart'); - } else { - error_log('[MPBC] Cart addition failed'); - $this->addFlash('eccube.front.cart.add.error', 'カートへの追加に失敗しました。'); } + + $this->addFlash('eccube.front.cart.add.error', 'カートへの追加に失敗しました。'); } catch (\Exception $e) { - error_log('[MPBC] Exception occurred: ' . $e->getMessage()); - error_log('[MPBC] Exception trace: ' . $e->getTraceAsString()); - $this->logger->error('[MPBC] Error in manual form processing', [ + $this->logger->error('[MPBC] Error adding product to cart', [ 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() ]); - $this->addFlash('eccube.front.request.error', 'エラーが発生しました: ' . $e->getMessage()); + $this->addFlash('eccube.front.request.error', 'エラーが発生しました。しばらくしてから再度お試しください。'); } } else { - error_log('[MPBC] Validation failed: productName="' . $productName . '", price=' . $price); - $this->addFlash('eccube.front.request.error', '商品名と価格を正しく入力してください。(商品名: ' . $productName . ', 価格: ' . $price . ')'); + $this->addFlash('eccube.front.request.error', '商品名と価格を正しく入力してください。'); } } - // Page entity for template compatibility $Page = new Page(); $Page->setUrl('mpb'); $Page->setName('商品作成'); - // プラグイン設定から情報を取得 $config = $this->configRepository->get(); $layout = null; if ($config && $config->getPageLayout()) { $layout = $this->layoutRepository->find($config->getPageLayout()); } - // カートの整合性をチェック(無効なProductClassを持つアイテムを削除) $this->cleanupInvalidCartItems(); return $this->render('@MPBC43/front/mpb.twig', [ @@ -190,166 +157,117 @@ public function index(Request $request) 'Page' => $Page, 'Layout' => $layout, 'page_title' => $config ? $config->getPageTitle() : null, - 'page_description' => $config ? $config->getPageDescription() : null + 'page_description' => $config ? $config->getPageDescription() : null, ]); } /** - * 商品を作成してカートに追加する + * 商品を作成してカートに追加する。 + * すべてのDB操作を単一トランザクションで行い、失敗時はロールバックする。 + * + * @param string $productName + * @param int $price + * @param string $sessionId + * @return bool + * @throws \Exception */ - private function createProductAndAddToCart($productName, $price, $sessionId) + private function createProductAndAddToCart(string $productName, int $price, string $sessionId): bool { + $connection = $this->entityManager->getConnection(); + $connection->beginTransaction(); + try { - error_log('[MPBC] Starting createProductAndAddToCart: name=' . $productName . ', price=' . $price); - - // 商品の作成 + // 商品エンティティ作成 $product = new Product(); $product->setName($productName); - // 商品を公開状態に設定(カートで使用するため) $product->setStatus($this->entityManager->find(ProductStatus::class, ProductStatus::DISPLAY_SHOW)); $product->setCreateDate(new \DateTime()); $product->setUpdateDate(new \DateTime()); - - // 商品説明を設定(セッション情報も含める) - $product->setDescriptionDetail('カスタム商品: ' . $productName . ' [セッション: ' . substr($sessionId, 0, 8) . ']'); + $product->setDescriptionDetail( + 'カスタム商品: ' . $productName . ' [セッション: ' . substr($sessionId, 0, 8) . ']' + ); $product->setDescriptionList('お客様専用のカスタム商品です。このセッションでのみ購入可能。'); - - error_log('[MPBC] Product entity created'); - // ProductClassの作成 + // ProductClassエンティティ作成 $productClass = new ProductClass(); $productClass->setProduct($product); - $productClass->setPrice01($price); // 通常価格を設定 - $productClass->setPrice02($price); // 販売価格も同じ値で設定 + $productClass->setPrice01($price); + $productClass->setPrice02($price); $productClass->setVisible(true); - // 在庫を1個限りに設定(一度限りの購入) $productClass->setStockUnlimited(false); $productClass->setStock(1); $productClass->setCreateDate(new \DateTime()); $productClass->setUpdateDate(new \DateTime()); - - // SaleTypeを設定 $productClass->setSaleType($this->entityManager->find(SaleType::class, SaleType::SALE_TYPE_NORMAL)); - - // デフォルトのProductClassとして設定 $productClass->setClassCategory1(null); $productClass->setClassCategory2(null); - - // ProductClassに重要なフィールドを設定 - $productClass->setCode(null); // 商品コードは自動生成 - $productClass->setDeliveryFee(null); // 個別送料なし - + $productClass->setCode(null); + $productClass->setDeliveryFee(null); $product->addProductClass($productClass); - error_log('[MPBC] ProductClass entity created'); - - // 商品をまず永続化してIDを生成 - $this->entityManager->persist($product); - $this->entityManager->persist($productClass); - $this->entityManager->flush(); // ここでIDが生成される - - error_log('[MPBC] Entities persisted and flushed'); - - // ProductStockエンティティを作成してProductClassに関連付け + // ProductStockエンティティ作成 $productStock = new ProductStock(); $productStock->setProductClass($productClass); $productStock->setStock(1); $productStock->setCreateDate(new \DateTime()); $productStock->setUpdateDate(new \DateTime()); - - // ProductClassとProductStockの双方向関連を設定 $productClass->setProductStock($productStock); + + // 単一トランザクションで一括永続化・フラッシュ + $this->entityManager->persist($product); + $this->entityManager->persist($productClass); $this->entityManager->persist($productStock); $this->entityManager->flush(); - error_log('[MPBC] ProductStock entity created and persisted'); - error_log('[MPBC] Final ProductClass ID: ' . $productClass->getId()); - error_log('[MPBC] Final Product ID: ' . $product->getId()); + $connection->commit(); - $this->logger->info('[MPBC] Created Product for cart', [ + $this->logger->info('[MPBC] Created custom product', [ 'product_id' => $product->getId(), 'product_class_id' => $productClass->getId(), - 'product_name' => $product->getName(), - 'price' => $productClass->getPrice02() + 'price' => $price, ]); - // カートに追加する前にエンティティをリフレッシュ - $this->entityManager->refresh($product); - $this->entityManager->refresh($productClass); - - // エンティティが確実に管理されるようにmerge - $productClass = $this->entityManager->merge($productClass); - $product = $this->entityManager->merge($product); - - // 商品とProductClassの状態を確認 - error_log('[MPBC] Product status: ' . $product->getStatus()->getName()); - error_log('[MPBC] ProductClass visible: ' . ($productClass->isVisible() ? 'YES' : 'NO')); - error_log('[MPBC] ProductClass stock: ' . $productClass->getStock()); - error_log('[MPBC] ProductClass ID for cart: ' . $productClass->getId()); - error_log('[MPBC] ProductClass Product relationship: ' . ($productClass->getProduct() ? 'OK' : 'NULL')); - - // カートに追加 - error_log('[MPBC] Adding product to cart'); - $this->cartService->addProduct($productClass, 1); - - // カートの状況をチェック - $Cart = $this->cartService->getCart(); - if ($Cart) { - $cartItems = $Cart->getCartItems(); - error_log('[MPBC] Cart items count after add: ' . count($cartItems)); - foreach ($cartItems as $item) { - error_log('[MPBC] Cart item: ' . $item->getProductClass()->getProduct()->getName() . ' - Quantity: ' . $item->getQuantity()); - } - } else { - error_log('[MPBC] Cart is null after addProduct'); - } - - // カートの購入フローを実行して合計金額を計算 - error_log('[MPBC] Executing cart purchase flow'); - if ($Cart) { - // フロー実行前の商品の詳細情報を確認 - $cartItems = $Cart->getCartItems(); - foreach ($cartItems as $item) { - $pc = $item->getProductClass(); - $prod = $pc->getProduct(); - error_log('[MPBC] Pre-flow - Product: ' . $prod->getName() . ', Status: ' . $prod->getStatus()->getName() . ', Visible: ' . ($pc->isVisible() ? 'YES' : 'NO') . ', Stock: ' . $pc->getStock()); - } - - $flowResult = $this->cartPurchaseFlow->validate($Cart, new PurchaseContext()); - error_log('[MPBC] Purchase flow has errors: ' . ($flowResult->hasError() ? 'YES' : 'NO')); - if ($flowResult->hasError()) { - foreach ($flowResult->getErrors() as $error) { - error_log('[MPBC] Purchase flow error: ' . $error->getMessage()); - } - } else { - $this->cartPurchaseFlow->commit($Cart, new PurchaseContext()); - error_log('[MPBC] Purchase flow committed successfully'); - } - $this->cartService->save(); - - // フロー実行後のカート状況をチェック - $cartItems = $Cart->getCartItems(); - error_log('[MPBC] Cart items count after flow: ' . count($cartItems)); - } - - error_log('[MPBC] Cart service completed'); - $this->logger->info('[MPBC] Added to cart successfully'); - - return true; } catch (\Exception $e) { - error_log('[MPBC] Exception in createProductAndAddToCart: ' . $e->getMessage()); - $this->logger->error('[MPBC] Exception in createProductAndAddToCart', [ + if ($connection->isTransactionActive()) { + $connection->rollBack(); + } + $this->logger->error('[MPBC] Failed to create product, transaction rolled back', [ 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() ]); throw $e; } + + // トランザクションコミット後にDB値を同期 + $this->entityManager->refresh($product); + $this->entityManager->refresh($productClass); + + // カートに追加 + $this->cartService->addProduct($productClass, 1); + + $Cart = $this->cartService->getCart(); + if ($Cart) { + $flowResult = $this->cartPurchaseFlow->validate($Cart, new PurchaseContext()); + if ($flowResult->hasError()) { + foreach ($flowResult->getErrors() as $error) { + $this->logger->warning('[MPBC] Purchase flow validation error', [ + 'message' => $error->getMessage(), + ]); + } + } else { + $this->cartPurchaseFlow->commit($Cart, new PurchaseContext()); + } + $this->cartService->save(); + } + + $this->logger->info('[MPBC] Product added to cart successfully'); + + return true; } /** - * カート内の無効なProductClassを持つアイテムを削除 + * カート内の無効なProductClassを持つアイテムを削除する */ - private function cleanupInvalidCartItems() + private function cleanupInvalidCartItems(): void { try { $Cart = $this->cartService->getCart(); @@ -360,47 +278,51 @@ private function cleanupInvalidCartItems() $itemsToRemove = []; foreach ($Cart->getCartItems() as $CartItem) { $ProductClass = $CartItem->getProductClass(); - - // ProductClassが存在しないか、Productが存在しない場合 + if (!$ProductClass || !$ProductClass->getId()) { $itemsToRemove[] = $CartItem; continue; } try { - // ProductClassがデータベースに存在するかチェック - $this->entityManager->find(ProductClass::class, $ProductClass->getId()); - - // Productが存在するかチェック - $Product = $ProductClass->getProduct(); + $dbProductClass = $this->entityManager->find(ProductClass::class, $ProductClass->getId()); + if (!$dbProductClass) { + $itemsToRemove[] = $CartItem; + continue; + } + + $Product = $dbProductClass->getProduct(); if (!$Product || !$Product->getId()) { $itemsToRemove[] = $CartItem; continue; } - // Productがデータベースに存在するかチェック - $this->entityManager->find(Product::class, $Product->getId()); - + $dbProduct = $this->entityManager->find(Product::class, $Product->getId()); + if (!$dbProduct) { + $itemsToRemove[] = $CartItem; + } } catch (\Exception $e) { - // エンティティが見つからない場合は削除対象に追加 $itemsToRemove[] = $CartItem; - error_log('[MPBC] Invalid cart item found: ' . $e->getMessage()); + $this->logger->warning('[MPBC] Invalid cart item detected during cleanup', [ + 'product_class_id' => $ProductClass ? $ProductClass->getId() : null, + 'error' => $e->getMessage(), + ]); } } - // 無効なアイテムを削除 - foreach ($itemsToRemove as $item) { - $Cart->removeCartItem($item); - error_log('[MPBC] Removed invalid cart item'); - } - if (!empty($itemsToRemove)) { + foreach ($itemsToRemove as $item) { + $Cart->removeCartItem($item); + } $this->cartService->save(); - error_log('[MPBC] Cleaned up ' . count($itemsToRemove) . ' invalid cart items'); + $this->logger->info('[MPBC] Cleaned up invalid cart items', [ + 'removed_count' => count($itemsToRemove), + ]); } - } catch (\Exception $e) { - error_log('[MPBC] Error cleaning up cart items: ' . $e->getMessage()); + $this->logger->error('[MPBC] Error during cart cleanup', [ + 'error' => $e->getMessage(), + ]); } } } diff --git a/EventListener/CartEventListener.php b/EventListener/CartEventListener.php index 1733d97..1a410dd 100755 --- a/EventListener/CartEventListener.php +++ b/EventListener/CartEventListener.php @@ -3,6 +3,8 @@ namespace Plugin\MPBC43\EventListener; use Doctrine\ORM\EntityManagerInterface; +use Eccube\Entity\Product; +use Eccube\Entity\ProductClass; use Eccube\Event\EccubeEvents; use Eccube\Event\EventArgs; use Eccube\Service\CartService; @@ -50,15 +52,14 @@ public function __construct( public static function getSubscribedEvents() { return [ - // CartEventListenerを一時的に無効化してEntityNotFoundExceptionを回避 - // EccubeEvents::FRONT_CART_ADD_COMPLETE => 'onCartAdd', - // EccubeEvents::FRONT_CART_INDEX_INITIALIZE => 'onCartIndex', + EccubeEvents::FRONT_CART_ADD_COMPLETE => 'onCartAdd', + EccubeEvents::FRONT_CART_INDEX_INITIALIZE => 'onCartIndex', ]; } /** * カート追加時の処理 - * + * * @param EventArgs $event */ public function onCartAdd(EventArgs $event) @@ -68,7 +69,7 @@ public function onCartAdd(EventArgs $event) /** * カート表示時の処理 - * + * * @param EventArgs $event */ public function onCartIndex(EventArgs $event) @@ -77,110 +78,99 @@ public function onCartIndex(EventArgs $event) } /** - * カート内の商品を検証し、不正な商品を削除 + * カート内の商品を検証し、不正な商品を削除する。 + * セッション不一致の商品、購入済み商品を除去する。 */ - private function validateCartProducts() + private function validateCartProducts(): void { $request = $this->requestStack->getCurrentRequest(); if (!$request) { return; } - $currentSessionId = $request->getSession()->getId(); $Cart = $this->cartService->getCart(); - if (!$Cart) { return; } - error_log('[MPBC] Cart validation started. Current session: ' . substr($currentSessionId, 0, 8)); - error_log('[MPBC] Cart has ' . count($Cart->getCartItems()) . ' items'); + $currentSessionId = $request->getSession()->getId(); + $currentSessionPrefix = substr($currentSessionId, 0, 8); - try { - $hasRemovedItems = false; + $hasRemovedItems = false; - foreach ($Cart->getCartItems() as $CartItem) { - $ProductClass = $CartItem->getProductClass(); - if (!$ProductClass) { - error_log('[MPBC] Cart item has no ProductClass - skipping'); - continue; - } + foreach ($Cart->getCartItems() as $CartItem) { + $ProductClass = $CartItem->getProductClass(); + if (!$ProductClass) { + continue; + } - $Product = $ProductClass->getProduct(); - if (!$Product) { - error_log('[MPBC] ProductClass has no Product - skipping'); - continue; + // エンティティの遅延ロードで EntityNotFoundException が発生する可能性があるため + // EntityManagerから直接取得して安全にチェックする + $Product = null; + try { + $productId = null; + if ($ProductClass->getProduct()) { + $productId = $ProductClass->getProduct()->getId(); } - - error_log('[MPBC] Validating product: ' . $Product->getName() . ' (ID: ' . $Product->getId() . ')'); - - // カスタム商品かどうかチェック - $description = $Product->getDescriptionDetail(); - if ($description && strpos($description, '[セッション:') !== false) { - // セッション情報を抽出 - if (preg_match('/\[セッション:\s*([^\]]+)\]/', $description, $matches)) { - $productSessionId = $matches[1]; - $currentSessionPrefix = substr($currentSessionId, 0, 8); - - error_log('[MPBC] Custom product session check: product=' . $productSessionId . ', current=' . $currentSessionPrefix); - - // セッションが一致しない場合は削除 - if ($productSessionId !== $currentSessionPrefix) { - error_log('[MPBC] Session mismatch - removing product from cart'); - $this->logger->info('[MPBC] Removing unauthorized custom product from cart', [ - 'product_id' => $Product->getId(), - 'product_name' => $Product->getName(), - 'product_session' => $productSessionId, - 'current_session' => $currentSessionPrefix - ]); - - // カートアイテムを安全に削除 - try { - $Cart->removeCartItem($CartItem); - $hasRemovedItems = true; - } catch (\Exception $e) { - error_log('[MPBC] Error removing cart item: ' . $e->getMessage()); - } - } else { - error_log('[MPBC] Session matches - keeping product'); - } - } - } else { - error_log('[MPBC] Not a custom product - keeping'); + if ($productId) { + $Product = $this->entityManager->find(Product::class, $productId); } + } catch (\Exception $e) { + $this->logger->warning('[MPBC] Could not load product for cart validation', [ + 'error' => $e->getMessage(), + ]); + continue; + } - // 購入済み商品かどうかチェック - if (strpos($Product->getName(), '[購入済み]') !== false) { - error_log('[MPBC] Product already purchased - removing'); - $this->logger->info('[MPBC] Removing already purchased product from cart', [ - 'product_id' => $Product->getId(), - 'product_name' => $Product->getName() - ]); + if (!$Product) { + continue; + } - // カートアイテムを安全に削除 - try { - $Cart->removeCartItem($CartItem); - $hasRemovedItems = true; - } catch (\Exception $e) { - error_log('[MPBC] Error removing purchased cart item: ' . $e->getMessage()); - } + // 購入済み商品チェック + if (strpos($Product->getName(), '[購入済み]') !== false) { + $this->logger->info('[MPBC] Removing already purchased product from cart', [ + 'product_id' => $Product->getId(), + 'product_name' => $Product->getName(), + ]); + try { + $Cart->removeCartItem($CartItem); + $hasRemovedItems = true; + } catch (\Exception $e) { + $this->logger->warning('[MPBC] Failed to remove purchased product from cart', [ + 'error' => $e->getMessage(), + ]); } + continue; } - if ($hasRemovedItems) { - $this->cartService->save(); - error_log('[MPBC] Cart cleaned up, unauthorized products removed'); - $this->logger->info('[MPBC] Cart cleaned up, unauthorized products removed'); - } else { - error_log('[MPBC] Cart validation completed - no items removed'); + // カスタム商品のセッション検証 + $description = $Product->getDescriptionDetail(); + if ($description && strpos($description, '[セッション:') !== false) { + if (preg_match('/\[セッション:\s*([^\]]+)\]/', $description, $matches)) { + $productSessionPrefix = trim($matches[1]); + + if ($productSessionPrefix !== $currentSessionPrefix) { + $this->logger->info('[MPBC] Removing unauthorized custom product from cart', [ + 'product_id' => $Product->getId(), + 'product_session' => $productSessionPrefix, + 'current_session' => $currentSessionPrefix, + ]); + try { + $Cart->removeCartItem($CartItem); + $hasRemovedItems = true; + } catch (\Exception $e) { + $this->logger->warning('[MPBC] Failed to remove unauthorized product from cart', [ + 'error' => $e->getMessage(), + ]); + } + } + } } + } - } catch (\Exception $e) { - error_log('[MPBC] Cart validation error: ' . $e->getMessage()); - $this->logger->error('[MPBC] Error validating cart products', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() - ]); + if ($hasRemovedItems) { + $this->cartService->save(); + $this->logger->info('[MPBC] Cart cleaned up, unauthorized products removed'); } } } diff --git a/EventListener/OrderEventListener.php b/EventListener/OrderEventListener.php index d0f5b4e..5edf4cd 100755 --- a/EventListener/OrderEventListener.php +++ b/EventListener/OrderEventListener.php @@ -39,63 +39,61 @@ public static function getSubscribedEvents() } /** - * 注文完了時の処理 - * + * 注文完了時の処理。 + * セッションマーカー付きカスタム商品を購入不可能な状態にする。 + * * @param EventArgs $event */ public function onShoppingComplete(EventArgs $event) { /** @var \Eccube\Entity\Order $Order */ $Order = $event->getArgument('Order'); - + if (!$Order) { return; } - try { - $this->logger->info('[MPBC] Order completed, checking for custom products', [ - 'order_id' => $Order->getId() - ]); + $this->logger->info('[MPBC] Order completed, checking for custom products', [ + 'order_id' => $Order->getId(), + ]); + + foreach ($Order->getOrderItems() as $OrderItem) { + $Product = $OrderItem->getProduct(); - // 注文内の商品をチェック - foreach ($Order->getOrderItems() as $OrderItem) { - $Product = $OrderItem->getProduct(); - - if (!$Product) { - continue; - } - - // カスタム商品(セッション情報を含む商品)かどうかチェック - $description = $Product->getDescriptionDetail(); - if ($description && strpos($description, '[セッション:') !== false) { - $this->logger->info('[MPBC] Found custom product in order, marking as unavailable', [ - 'product_id' => $Product->getId(), - 'product_name' => $Product->getName() - ]); - - // 商品を購入不可能な状態に変更 - $this->disableCustomProduct($Product); - } + if (!$Product) { + continue; } - $this->entityManager->flush(); + $description = $Product->getDescriptionDetail(); + if (!$description || strpos($description, '[セッション:') === false) { + continue; + } - } catch (\Exception $e) { - $this->logger->error('[MPBC] Error in order completion handler', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString() + $this->logger->info('[MPBC] Found custom product in order, marking as purchased', [ + 'product_id' => $Product->getId(), + 'product_name' => $Product->getName(), ]); + + try { + $this->disableCustomProduct($Product); + $this->entityManager->flush(); + } catch (\Exception $e) { + $this->logger->error('[MPBC] Failed to disable custom product after order', [ + 'product_id' => $Product->getId(), + 'error' => $e->getMessage(), + ]); + // 他の商品の処理を継続する + } } } /** - * カスタム商品を無効化 - * + * カスタム商品を購入済み状態にして再購入を防止する + * * @param \Eccube\Entity\Product $Product */ - private function disableCustomProduct(\Eccube\Entity\Product $Product) + private function disableCustomProduct(\Eccube\Entity\Product $Product): void { - // すべてのProductClassを非表示にして購入を不可能にする foreach ($Product->getProductClasses() as $ProductClass) { $ProductClass->setVisible(false); $ProductClass->setStockUnlimited(false); @@ -103,7 +101,6 @@ private function disableCustomProduct(\Eccube\Entity\Product $Product) $this->entityManager->persist($ProductClass); } - // 商品名に購入済みマークを追加 $currentName = $Product->getName(); if (strpos($currentName, '[購入済み]') === false) { $Product->setName($currentName . ' [購入済み]'); @@ -113,7 +110,7 @@ private function disableCustomProduct(\Eccube\Entity\Product $Product) $this->logger->info('[MPBC] Custom product disabled after purchase', [ 'product_id' => $Product->getId(), - 'product_name' => $Product->getName() + 'product_name' => $Product->getName(), ]); } } diff --git a/EventListener/ProductListEventListener.php b/EventListener/ProductListEventListener.php index fff5526..0396aee 100755 --- a/EventListener/ProductListEventListener.php +++ b/EventListener/ProductListEventListener.php @@ -47,10 +47,9 @@ public function onProductIndexSearch(EventArgs $event) return; } - $sessionId = $request->getSession()->getId(); - - // カスタム商品(セッション情報を含む商品)を除外 - $qb->andWhere('p.description_detail NOT LIKE :session_filter') - ->setParameter('session_filter', '%セッション: ' . substr($sessionId, 0, 8) . '%'); + // カスタム商品(セッション情報を含む商品)を全て除外する + // 特定セッションだけでなく、全MPBCカスタム商品を一覧から非表示にする + $qb->andWhere('p.descriptionDetail NOT LIKE :session_filter OR p.descriptionDetail IS NULL') + ->setParameter('session_filter', '%[セッション:%'); } } diff --git a/PluginManager.php b/PluginManager.php index c812de3..ab04cc0 100755 --- a/PluginManager.php +++ b/PluginManager.php @@ -39,7 +39,6 @@ public function enable(array $meta, ContainerInterface $container) $entityManager->flush($Config); } } - $Config = $this->createConfig($entityManager); // Ensure a hidden base product with a single class exists for MPBC $productRepo = $entityManager->getRepository(Product::class); diff --git a/Tests/Stubs/EccubeStubs.php b/Tests/Stubs/EccubeStubs.php new file mode 100644 index 0000000..f6d864c --- /dev/null +++ b/Tests/Stubs/EccubeStubs.php @@ -0,0 +1,932 @@ +elements = $elements; + } + + public function add($element): bool + { + $this->elements[] = $element; + return true; + } + + public function removeElement($element): bool + { + $key = array_search($element, $this->elements, true); + if ($key === false) { + return false; + } + unset($this->elements[$key]); + $this->elements = array_values($this->elements); + return true; + } + + public function count(): int + { + return count($this->elements); + } + + public function getIterator(): \ArrayIterator + { + return new \ArrayIterator($this->elements); + } + + public function toArray(): array + { + return $this->elements; + } + + public function isEmpty(): bool + { + return empty($this->elements); + } + } +} + +// --------------------------------------------------------------------------- +// EC-CUBE Event系 +// --------------------------------------------------------------------------- +namespace Eccube\Event { + class EventArgs + { + private array $args; + + public function __construct(array $args = []) + { + $this->args = $args; + } + + public function getArgument(string $key) + { + return $this->args[$key] ?? null; + } + + public function setArgument(string $key, $value): void + { + $this->args[$key] = $value; + } + } + + class EccubeEvents + { + const FRONT_SHOPPING_COMPLETE_INITIALIZE = 'eccube.event.front.shopping.complete.initialize'; + const FRONT_CART_INDEX_INITIALIZE = 'eccube.event.front.cart.index.initialize'; + const FRONT_CART_ADD_COMPLETE = 'eccube.event.front.cart.add.complete'; + const FRONT_PRODUCT_INDEX_SEARCH = 'eccube.event.front.product.index.search'; + } +} + +// --------------------------------------------------------------------------- +// EC-CUBE Master entities +// --------------------------------------------------------------------------- +namespace Eccube\Entity\Master { + class ProductStatus + { + const DISPLAY_SHOW = 1; + const DISPLAY_HIDE = 2; + + private $id; + private $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function setName($name): self + { + $this->name = $name; + return $this; + } + } + + class SaleType + { + const SALE_TYPE_NORMAL = 1; + + private $id; + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + } + + class OrderStatus + { + const NEW = 1; + const CANCEL = 3; + const DELIVERED = 5; + const PAID = 6; + + private $id; + private $name; + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function setName($name): self + { + $this->name = $name; + return $this; + } + } +} + +// --------------------------------------------------------------------------- +// EC-CUBE Core Entities +// --------------------------------------------------------------------------- +namespace Eccube\Entity { + use Doctrine\Common\Collections\ArrayCollection; + + class Product + { + private $id; + private $name; + private $status; + private $descriptionDetail; + private $descriptionList; + private $createDate; + private $updateDate; + private $productClasses; + + public function __construct() + { + $this->productClasses = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + + public function getName() + { + return $this->name; + } + + public function setName($name): self + { + $this->name = $name; + return $this; + } + + public function getStatus() + { + return $this->status; + } + + public function setStatus($status): self + { + $this->status = $status; + return $this; + } + + public function getDescriptionDetail() + { + return $this->descriptionDetail; + } + + public function setDescriptionDetail($description): self + { + $this->descriptionDetail = $description; + return $this; + } + + public function getDescriptionList() + { + return $this->descriptionList; + } + + public function setDescriptionList($description): self + { + $this->descriptionList = $description; + return $this; + } + + public function getCreateDate() + { + return $this->createDate; + } + + public function setCreateDate($date): self + { + $this->createDate = $date; + return $this; + } + + public function getUpdateDate() + { + return $this->updateDate; + } + + public function setUpdateDate($date): self + { + $this->updateDate = $date; + return $this; + } + + public function getProductClasses() + { + return $this->productClasses; + } + + public function addProductClass($productClass): self + { + $this->productClasses->add($productClass); + return $this; + } + } + + class ProductClass + { + private $id; + private $product; + private $price01; + private $price02; + private $visible = true; + private $stockUnlimited = false; + private $stock; + private $saleType; + private $classCategory1; + private $classCategory2; + private $code; + private $deliveryFee; + private $productStock; + private $createDate; + private $updateDate; + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + + public function getProduct() + { + return $this->product; + } + + public function setProduct($product): self + { + $this->product = $product; + return $this; + } + + public function getPrice01() + { + return $this->price01; + } + + public function setPrice01($price): self + { + $this->price01 = $price; + return $this; + } + + public function getPrice02() + { + return $this->price02; + } + + public function setPrice02($price): self + { + $this->price02 = $price; + return $this; + } + + public function isVisible() + { + return $this->visible; + } + + public function setVisible($visible): self + { + $this->visible = $visible; + return $this; + } + + public function isStockUnlimited() + { + return $this->stockUnlimited; + } + + public function setStockUnlimited($unlimited): self + { + $this->stockUnlimited = $unlimited; + return $this; + } + + public function getStock() + { + return $this->stock; + } + + public function setStock($stock): self + { + $this->stock = $stock; + return $this; + } + + public function getSaleType() + { + return $this->saleType; + } + + public function setSaleType($saleType): self + { + $this->saleType = $saleType; + return $this; + } + + public function getClassCategory1() + { + return $this->classCategory1; + } + + public function setClassCategory1($category): self + { + $this->classCategory1 = $category; + return $this; + } + + public function getClassCategory2() + { + return $this->classCategory2; + } + + public function setClassCategory2($category): self + { + $this->classCategory2 = $category; + return $this; + } + + public function getCode() + { + return $this->code; + } + + public function setCode($code): self + { + $this->code = $code; + return $this; + } + + public function getDeliveryFee() + { + return $this->deliveryFee; + } + + public function setDeliveryFee($fee): self + { + $this->deliveryFee = $fee; + return $this; + } + + public function getProductStock() + { + return $this->productStock; + } + + public function setProductStock($productStock): self + { + $this->productStock = $productStock; + return $this; + } + + public function getCreateDate() + { + return $this->createDate; + } + + public function setCreateDate($date): self + { + $this->createDate = $date; + return $this; + } + + public function getUpdateDate() + { + return $this->updateDate; + } + + public function setUpdateDate($date): self + { + $this->updateDate = $date; + return $this; + } + } + + class ProductStock + { + private $productClass; + private $stock; + private $createDate; + private $updateDate; + + public function getProductClass() + { + return $this->productClass; + } + + public function setProductClass($productClass): self + { + $this->productClass = $productClass; + return $this; + } + + public function getStock() + { + return $this->stock; + } + + public function setStock($stock): self + { + $this->stock = $stock; + return $this; + } + + public function setCreateDate($date): self + { + $this->createDate = $date; + return $this; + } + + public function setUpdateDate($date): self + { + $this->updateDate = $date; + return $this; + } + } + + class Cart + { + private $cartItems; + + public function __construct() + { + $this->cartItems = new \Doctrine\Common\Collections\ArrayCollection(); + } + + public function getCartItems() + { + return $this->cartItems; + } + + public function addCartItem($item): self + { + $this->cartItems->add($item); + return $this; + } + + public function removeCartItem($item): self + { + $this->cartItems->removeElement($item); + return $this; + } + } + + class CartItem + { + private $productClass; + private $quantity; + + public function getProductClass() + { + return $this->productClass; + } + + public function setProductClass($productClass): self + { + $this->productClass = $productClass; + return $this; + } + + public function getQuantity() + { + return $this->quantity; + } + + public function setQuantity($quantity): self + { + $this->quantity = $quantity; + return $this; + } + } + + class Order + { + private $id; + private $orderItems; + private $orderStatus; + + public function __construct() + { + $this->orderItems = new \Doctrine\Common\Collections\ArrayCollection(); + } + + public function getId() + { + return $this->id; + } + + public function setId($id): self + { + $this->id = $id; + return $this; + } + + public function getOrderItems() + { + return $this->orderItems; + } + + public function getOrderStatus() + { + return $this->orderStatus; + } + + public function setOrderStatus($status): self + { + $this->orderStatus = $status; + return $this; + } + } + + class OrderItem + { + private $product; + private $productClass; + private $productName; + + public function getProduct() + { + return $this->product; + } + + public function setProduct($product): self + { + $this->product = $product; + return $this; + } + + public function getProductClass() + { + return $this->productClass; + } + + public function setProductClass($productClass): self + { + $this->productClass = $productClass; + return $this; + } + + public function getProductName() + { + return $this->productName; + } + + public function setProductName($name): self + { + $this->productName = $name; + return $this; + } + } + + class Page + { + private $url; + private $name; + + public function setUrl($url): self + { + $this->url = $url; + return $this; + } + + public function getUrl() + { + return $this->url; + } + + public function setName($name): self + { + $this->name = $name; + return $this; + } + + public function getName() + { + return $this->name; + } + } + + class Layout + { + private $id; + + public function getId() + { + return $this->id; + } + } +} + +// --------------------------------------------------------------------------- +// EC-CUBE Controller +// --------------------------------------------------------------------------- +namespace Eccube\Controller { + abstract class AbstractController + { + protected $entityManager; + + protected function createForm($type, $data = null, array $options = []) + { + return new class { + public function createView() { return null; } + public function handleRequest($request) {} + public function isSubmitted() { return false; } + public function isValid() { return false; } + }; + } + + protected function addFlash($type, $message): void {} + + protected function addSuccess($message, $context = 'front'): void {} + + protected function redirectToRoute($route, array $params = []) {} + + protected function render($template, array $params = []) { return $params; } + } +} + +// --------------------------------------------------------------------------- +// EC-CUBE Repository +// --------------------------------------------------------------------------- +namespace Eccube\Repository { + abstract class AbstractRepository extends \Doctrine\ORM\EntityRepository + { + } + + class LayoutRepository extends AbstractRepository {} + class ProductRepository extends AbstractRepository {} +} + +// --------------------------------------------------------------------------- +// EC-CUBE Service +// --------------------------------------------------------------------------- +namespace Eccube\Service { + class CartService + { + public function addProduct($productClass, $quantity): void {} + public function getCart() { return null; } + public function save(): void {} + } +} + +namespace Eccube\Service\PurchaseFlow { + class PurchaseContext {} + + class PurchaseError + { + private $message; + + public function __construct(string $message) + { + $this->message = $message; + } + + public function getMessage(): string + { + return $this->message; + } + } + + class PurchaseFlowResult + { + private bool $hasError; + private array $errors; + + public function __construct(bool $hasError = false, array $errors = []) + { + $this->hasError = $hasError; + $this->errors = $errors; + } + + public function hasError(): bool + { + return $this->hasError; + } + + public function getErrors(): array + { + return $this->errors; + } + } + + class PurchaseFlow + { + public function validate($cart, $context): PurchaseFlowResult + { + return new PurchaseFlowResult(false); + } + + public function commit($cart, $context): void {} + } +} diff --git a/Tests/Unit/Controller/MpbControllerTest.php b/Tests/Unit/Controller/MpbControllerTest.php new file mode 100644 index 0000000..ee5cb4b --- /dev/null +++ b/Tests/Unit/Controller/MpbControllerTest.php @@ -0,0 +1,278 @@ +connection = Mockery::mock(Connection::class); + $this->connection->shouldReceive('beginTransaction')->byDefault(); + $this->connection->shouldReceive('commit')->byDefault(); + $this->connection->shouldReceive('isTransactionActive')->andReturn(false)->byDefault(); + + $this->entityManager = Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class); + $this->entityManager->shouldReceive('getConnection') + ->andReturn($this->connection)->byDefault(); + + $productStatus = new ProductStatus(); + $productStatus->setId(ProductStatus::DISPLAY_SHOW); + $productStatus->setName('表示'); + + $saleType = new SaleType(); + $saleType->setId(SaleType::SALE_TYPE_NORMAL); + + $this->entityManager->shouldReceive('find') + ->with(ProductStatus::class, ProductStatus::DISPLAY_SHOW) + ->andReturn($productStatus)->byDefault(); + $this->entityManager->shouldReceive('find') + ->with(SaleType::class, SaleType::SALE_TYPE_NORMAL) + ->andReturn($saleType)->byDefault(); + + $this->cartService = Mockery::mock(\Eccube\Service\CartService::class); + $this->purchaseFlow = Mockery::mock(\Eccube\Service\PurchaseFlow\PurchaseFlow::class); + $this->configRepository = Mockery::mock(\Plugin\MPBC43\Repository\ConfigRepository::class); + $this->productRepository = Mockery::mock(\Eccube\Repository\ProductRepository::class); + $this->layoutRepository = Mockery::mock(\Eccube\Repository\LayoutRepository::class); + $this->logger = Mockery::mock(\Psr\Log\LoggerInterface::class)->shouldIgnoreMissing(); + } + + protected function tearDown(): void + { + Mockery::close(); + } + + private function buildController(): MpbController + { + return new MpbController( + $this->productRepository, + $this->cartService, + $this->entityManager, + $this->logger, + $this->purchaseFlow, + $this->configRepository, + $this->layoutRepository + ); + } + + private function invokePrivate(object $obj, string $method, array $args = []) + { + $ref = new \ReflectionMethod($obj, $method); + $ref->setAccessible(true); + return $ref->invokeArgs($obj, $args); + } + + // --------------------------------------------------------------------------- + // 価格サニタイズのテスト + // --------------------------------------------------------------------------- + + /** + * @dataProvider priceCleanupProvider + */ + public function testPriceCleanupExtractsNumbersOnly(string $raw, int $expected): void + { + $clean = preg_replace('/[^\d]/', '', $raw); + $price = (int) $clean; + $this->assertSame($expected, $price, "Failed for input: $raw"); + } + + public function priceCleanupProvider(): array + { + return [ + '¥とカンマ' => ['¥1,000', 1000], + '数字のみ' => ['5000', 5000], + '全角¥' => ['¥2,500', 2500], + '円表記なし大きな数' => ['10000', 10000], + 'スペース混入' => [' 3 000 ', 3000], + ]; + } + + // --------------------------------------------------------------------------- + // バリデーションロジックのテスト + // --------------------------------------------------------------------------- + + public function testEmptyProductNameFailsValidation(): void + { + $productName = ''; + $price = 1000; + $passes = !empty($productName) && $price > 0; + $this->assertFalse($passes); + } + + public function testZeroPriceFailsValidation(): void + { + $productName = 'テスト商品'; + $price = 0; + $passes = !empty($productName) && $price > 0; + $this->assertFalse($passes); + } + + public function testNegativePriceFailsValidation(): void + { + $productName = 'テスト商品'; + $price = -100; + $passes = !empty($productName) && $price > 0; + $this->assertFalse($passes); + } + + public function testValidInputPassesValidation(): void + { + $productName = 'テスト商品'; + $price = 5000; + $passes = !empty($productName) && $price > 0; + $this->assertTrue($passes); + } + + public function testWhitespaceOnlyProductNameIsHandled(): void + { + // PHPのempty()はスペースのみの文字列をfalseと判定しない + // 実際の処理でも同様に扱われる + $productName = ' '; + $price = 1000; + $passes = !empty($productName) && $price > 0; + $this->assertTrue($passes); // スペースのみでもempty()はtrueを返さない + } + + // --------------------------------------------------------------------------- + // createProductAndAddToCart のテスト + // --------------------------------------------------------------------------- + + public function testCreateProductAndAddToCartSucceeds(): void + { + $cart = new Cart(); + + $flowResult = new PurchaseFlowResult(false); + + $this->connection->shouldReceive('beginTransaction')->once(); + $this->connection->shouldReceive('commit')->once(); + $this->connection->shouldReceive('isTransactionActive')->andReturn(false); + + $this->entityManager->shouldReceive('persist')->times(3); + $this->entityManager->shouldReceive('flush')->once(); + $this->entityManager->shouldReceive('refresh')->twice(); + + $this->cartService->shouldReceive('addProduct')->once(); + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->purchaseFlow->shouldReceive('validate')->andReturn($flowResult); + $this->purchaseFlow->shouldReceive('commit')->once(); + $this->cartService->shouldReceive('save')->once(); + + $controller = $this->buildController(); + $result = $this->invokePrivate( + $controller, + 'createProductAndAddToCart', + ['テスト商品', 5000, 'abcdef1234567890'] + ); + + $this->assertTrue($result); + } + + public function testTransactionIsRolledBackOnPersistFailure(): void + { + $this->connection->shouldReceive('beginTransaction')->once(); + $this->connection->shouldReceive('commit')->never(); + $this->connection->shouldReceive('isTransactionActive')->andReturn(true); + $this->connection->shouldReceive('rollBack')->once(); + + $this->entityManager->shouldReceive('persist') + ->andThrow(new \RuntimeException('DB error')); + $this->entityManager->shouldReceive('flush')->never(); + + $controller = $this->buildController(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('DB error'); + + $this->invokePrivate( + $controller, + 'createProductAndAddToCart', + ['テスト商品', 5000, 'abcdef1234567890'] + ); + } + + public function testSessionPrefixIsEmbeddedInProductDescription(): void + { + $cart = new Cart(); + $flowResult = new PurchaseFlowResult(false); + + $this->connection->shouldReceive('beginTransaction')->once(); + $this->connection->shouldReceive('commit')->once(); + $this->connection->shouldReceive('isTransactionActive')->andReturn(false); + + $capturedProduct = null; + $this->entityManager->shouldReceive('persist') + ->andReturnUsing(function ($entity) use (&$capturedProduct) { + if ($entity instanceof \Eccube\Entity\Product) { + $capturedProduct = $entity; + } + }); + $this->entityManager->shouldReceive('flush')->once(); + $this->entityManager->shouldReceive('refresh'); + + $this->cartService->shouldReceive('addProduct')->once(); + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->purchaseFlow->shouldReceive('validate')->andReturn($flowResult); + $this->purchaseFlow->shouldReceive('commit'); + $this->cartService->shouldReceive('save')->once(); + + $controller = $this->buildController(); + $this->invokePrivate( + $controller, + 'createProductAndAddToCart', + ['テスト商品', 3000, 'xyzabc1234567890'] + ); + + $this->assertNotNull($capturedProduct); + $this->assertStringContainsString('[セッション: xyzabc12]', $capturedProduct->getDescriptionDetail()); + } + + public function testCartIsNotSavedWhenPurchaseFlowHasErrors(): void + { + $cart = new Cart(); + $errorResult = new PurchaseFlowResult(true, [ + new \Eccube\Service\PurchaseFlow\PurchaseError('在庫切れです'), + ]); + + $this->connection->shouldReceive('beginTransaction')->once(); + $this->connection->shouldReceive('commit')->once(); + $this->connection->shouldReceive('isTransactionActive')->andReturn(false); + + $this->entityManager->shouldReceive('persist')->times(3); + $this->entityManager->shouldReceive('flush')->once(); + $this->entityManager->shouldReceive('refresh')->twice(); + + $this->cartService->shouldReceive('addProduct')->once(); + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->purchaseFlow->shouldReceive('validate')->andReturn($errorResult); + $this->purchaseFlow->shouldNotReceive('commit'); + // エラーがあってもsave()は呼ばれる(カート状態を保存するため) + $this->cartService->shouldReceive('save')->once(); + + $controller = $this->buildController(); + $result = $this->invokePrivate( + $controller, + 'createProductAndAddToCart', + ['エラー商品', 1000, 'session123456789'] + ); + + $this->assertTrue($result); + } +} diff --git a/Tests/Unit/Entity/ConfigTest.php b/Tests/Unit/Entity/ConfigTest.php new file mode 100644 index 0000000..4ea804d --- /dev/null +++ b/Tests/Unit/Entity/ConfigTest.php @@ -0,0 +1,95 @@ +assertSame('customer_input', $config->getProductNameDisplayType()); + } + + public function testSetGetProductNameDisplayType(): void + { + $config = new Config(); + $config->setProductNameDisplayType('predefined_name'); + $this->assertSame('predefined_name', $config->getProductNameDisplayType()); + } + + public function testFluentInterfaceForProductNameDisplayType(): void + { + $config = new Config(); + $result = $config->setProductNameDisplayType('customer_input'); + $this->assertSame($config, $result); + } + + public function testGetIdReturnsNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getId()); + } + + public function testPredefinedProductNameIsNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getPredefinedProductName()); + } + + public function testSetGetPredefinedProductName(): void + { + $config = new Config(); + $config->setPredefinedProductName('テスト商品'); + $this->assertSame('テスト商品', $config->getPredefinedProductName()); + } + + public function testSetPredefinedProductNameToNull(): void + { + $config = new Config(); + $config->setPredefinedProductName('商品名'); + $config->setPredefinedProductName(null); + $this->assertNull($config->getPredefinedProductName()); + } + + public function testPageLayoutIsNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getPageLayout()); + } + + public function testSetGetPageLayout(): void + { + $config = new Config(); + $config->setPageLayout(2); + $this->assertSame(2, $config->getPageLayout()); + } + + public function testPageTitleIsNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getPageTitle()); + } + + public function testSetGetPageTitle(): void + { + $config = new Config(); + $config->setPageTitle('カスタムタイトル'); + $this->assertSame('カスタムタイトル', $config->getPageTitle()); + } + + public function testPageDescriptionIsNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getPageDescription()); + } + + public function testSetGetPageDescription(): void + { + $config = new Config(); + $config->setPageDescription('詳細な説明文をここに記載します。'); + $this->assertSame('詳細な説明文をここに記載します。', $config->getPageDescription()); + } +} diff --git a/Tests/Unit/EventListener/CartEventListenerTest.php b/Tests/Unit/EventListener/CartEventListenerTest.php new file mode 100644 index 0000000..efe2971 --- /dev/null +++ b/Tests/Unit/EventListener/CartEventListenerTest.php @@ -0,0 +1,202 @@ +assertArrayHasKey(EccubeEvents::FRONT_CART_ADD_COMPLETE, $events); + $this->assertSame('onCartAdd', $events[EccubeEvents::FRONT_CART_ADD_COMPLETE]); + + $this->assertArrayHasKey(EccubeEvents::FRONT_CART_INDEX_INITIALIZE, $events); + $this->assertSame('onCartIndex', $events[EccubeEvents::FRONT_CART_INDEX_INITIALIZE]); + } + + private function buildListener(string $currentSessionId): array + { + $em = Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class); + $logger = Mockery::mock(\Psr\Log\LoggerInterface::class)->shouldIgnoreMissing(); + $cartService = Mockery::mock(\Eccube\Service\CartService::class); + + $session = Mockery::mock(\Symfony\Component\HttpFoundation\Session\SessionInterface::class); + $session->shouldReceive('getId')->andReturn($currentSessionId); + + $request = Mockery::mock(\Symfony\Component\HttpFoundation\Request::class); + $request->shouldReceive('getSession')->andReturn($session); + + $requestStack = Mockery::mock(\Symfony\Component\HttpFoundation\RequestStack::class); + $requestStack->shouldReceive('getCurrentRequest')->andReturn($request); + + $listener = new CartEventListener($em, $logger, $requestStack, $cartService); + return [$listener, $cartService, $em]; + } + + public function testProductFromSameSessionIsKept(): void + { + [$listener, $cartService, $em] = $this->buildListener('abcdef1234567890'); + + $product = new Product(); + $product->setId(1); + $product->setName('テスト商品'); + $product->setDescriptionDetail('カスタム商品: test [セッション: abcdef12]'); + + $productClass = new ProductClass(); + $productClass->setId(10); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $cartService->shouldReceive('getCart')->andReturn($cart); + $em->shouldReceive('find') + ->with(\Eccube\Entity\Product::class, 1) + ->andReturn($product); + $cartService->shouldNotReceive('save'); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + + $this->assertCount(1, $cart->getCartItems()); + } + + public function testProductFromDifferentSessionIsRemoved(): void + { + [$listener, $cartService, $em] = $this->buildListener('zzzzzzzz99999999'); + + $product = new Product(); + $product->setId(2); + $product->setName('別セッション商品'); + $product->setDescriptionDetail('カスタム商品: test [セッション: abcdef12]'); + + $productClass = new ProductClass(); + $productClass->setId(20); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $cartService->shouldReceive('getCart')->andReturn($cart); + $em->shouldReceive('find') + ->with(\Eccube\Entity\Product::class, 2) + ->andReturn($product); + $cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testAlreadyPurchasedProductIsRemoved(): void + { + [$listener, $cartService, $em] = $this->buildListener('abcdef1234567890'); + + $product = new Product(); + $product->setId(3); + $product->setName('テスト商品 [購入済み]'); + $product->setDescriptionDetail('カスタム商品: test [セッション: abcdef12]'); + + $productClass = new ProductClass(); + $productClass->setId(30); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $cartService->shouldReceive('getCart')->andReturn($cart); + $em->shouldReceive('find') + ->with(\Eccube\Entity\Product::class, 3) + ->andReturn($product); + $cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testNullCartIsHandledGracefully(): void + { + [$listener, $cartService] = $this->buildListener('abcdef1234567890'); + + $cartService->shouldReceive('getCart')->andReturn(null); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + $this->assertTrue(true); // 例外なく完了 + } + + public function testNullRequestIsHandledGracefully(): void + { + $em = Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class); + $logger = Mockery::mock(\Psr\Log\LoggerInterface::class)->shouldIgnoreMissing(); + $cartService = Mockery::mock(\Eccube\Service\CartService::class); + + $requestStack = Mockery::mock(\Symfony\Component\HttpFoundation\RequestStack::class); + $requestStack->shouldReceive('getCurrentRequest')->andReturn(null); + + $listener = new CartEventListener($em, $logger, $requestStack, $cartService); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + $this->assertTrue(true); // 例外なく完了 + } + + public function testNonCustomProductIsNotAffected(): void + { + [$listener, $cartService, $em] = $this->buildListener('abcdef1234567890'); + + $product = new Product(); + $product->setId(4); + $product->setName('通常商品'); + $product->setDescriptionDetail('通常の商品説明文'); + + $productClass = new ProductClass(); + $productClass->setId(40); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $cartService->shouldReceive('getCart')->andReturn($cart); + $em->shouldReceive('find') + ->with(\Eccube\Entity\Product::class, 4) + ->andReturn($product); + $cartService->shouldNotReceive('save'); + + $event = new EventArgs([]); + $listener->onCartIndex($event); + + $this->assertCount(1, $cart->getCartItems()); + } +} diff --git a/Tests/Unit/EventListener/CartIntegrityEventListenerTest.php b/Tests/Unit/EventListener/CartIntegrityEventListenerTest.php new file mode 100644 index 0000000..6d06270 --- /dev/null +++ b/Tests/Unit/EventListener/CartIntegrityEventListenerTest.php @@ -0,0 +1,213 @@ +entityManager = Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class); + $this->logger = Mockery::mock(\Psr\Log\LoggerInterface::class)->shouldIgnoreMissing(); + $this->cartService = Mockery::mock(\Eccube\Service\CartService::class); + + $this->listener = new CartIntegrityEventListener( + $this->entityManager, + $this->logger, + $this->cartService + ); + } + + protected function tearDown(): void + { + Mockery::close(); + } + + public function testSubscribedEventsContainCartIndex(): void + { + $events = CartIntegrityEventListener::getSubscribedEvents(); + $this->assertArrayHasKey(EccubeEvents::FRONT_CART_INDEX_INITIALIZE, $events); + $this->assertSame('onCartIndex', $events[EccubeEvents::FRONT_CART_INDEX_INITIALIZE]); + } + + public function testNullCartIsHandledGracefully(): void + { + $this->cartService->shouldReceive('getCart')->andReturn(null); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + $this->assertTrue(true); // 例外なく完了 + } + + public function testItemWithNullProductClassIsRemoved(): void + { + $cartItem = new CartItem(); + // productClassをセットしない + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testItemWithProductClassWithoutIdIsRemoved(): void + { + $productClass = new ProductClass(); + // IDをセットしない(persist前の状態) + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testItemWithDeletedProductClassIsRemoved(): void + { + $productClass = new ProductClass(); + $productClass->setId(999); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->entityManager->shouldReceive('find') + ->with(ProductClass::class, 999) + ->andReturn(null); // DBに存在しない + $this->cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testItemWithDeletedProductIsRemoved(): void + { + $product = new Product(); + $product->setId(50); + + $productClass = new ProductClass(); + $productClass->setId(5); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->entityManager->shouldReceive('find') + ->with(ProductClass::class, 5) + ->andReturn($productClass); + $this->entityManager->shouldReceive('find') + ->with(Product::class, 50) + ->andReturn(null); // Productが削除済み + $this->cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testValidItemIsKept(): void + { + $product = new Product(); + $product->setId(10); + + $productClass = new ProductClass(); + $productClass->setId(5); + $productClass->setProduct($product); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->entityManager->shouldReceive('find') + ->with(ProductClass::class, 5) + ->andReturn($productClass); + $this->entityManager->shouldReceive('find') + ->with(Product::class, 10) + ->andReturn($product); + + // 有効なアイテムのみなのでsave()は呼ばれない + $this->cartService->shouldNotReceive('save'); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(1, $cart->getCartItems()); + } + + public function testEmptyCartIsHandledGracefully(): void + { + $cart = new Cart(); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->cartService->shouldNotReceive('save'); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } + + public function testExceptionDuringEntityFindMarksItemForRemoval(): void + { + $productClass = new ProductClass(); + $productClass->setId(77); + + $cartItem = new CartItem(); + $cartItem->setProductClass($productClass); + + $cart = new Cart(); + $cart->addCartItem($cartItem); + + $this->cartService->shouldReceive('getCart')->andReturn($cart); + $this->entityManager->shouldReceive('find') + ->with(ProductClass::class, 77) + ->andThrow(new \RuntimeException('DB connection error')); + $this->cartService->shouldReceive('save')->once(); + + $event = new EventArgs([]); + $this->listener->onCartIndex($event); + + $this->assertCount(0, $cart->getCartItems()); + } +} diff --git a/Tests/Unit/EventListener/OrderEventListenerTest.php b/Tests/Unit/EventListener/OrderEventListenerTest.php new file mode 100644 index 0000000..d00fa95 --- /dev/null +++ b/Tests/Unit/EventListener/OrderEventListenerTest.php @@ -0,0 +1,224 @@ +entityManager = Mockery::mock(\Doctrine\ORM\EntityManagerInterface::class); + $this->logger = Mockery::mock(\Psr\Log\LoggerInterface::class)->shouldIgnoreMissing(); + $this->listener = new OrderEventListener($this->entityManager, $this->logger); + } + + protected function tearDown(): void + { + Mockery::close(); + } + + public function testSubscribedEventsContainShoppingComplete(): void + { + $events = OrderEventListener::getSubscribedEvents(); + $this->assertArrayHasKey(EccubeEvents::FRONT_SHOPPING_COMPLETE_INITIALIZE, $events); + $this->assertSame('onShoppingComplete', $events[EccubeEvents::FRONT_SHOPPING_COMPLETE_INITIALIZE]); + } + + public function testNullOrderIsHandledGracefully(): void + { + $event = new EventArgs(['Order' => null]); + // 例外なく実行できることを確認 + $this->listener->onShoppingComplete($event); + $this->assertTrue(true); + } + + public function testCustomProductIsDisabledAfterOrderCompletion(): void + { + $orderStatus = new OrderStatus(); + $orderStatus->setId(OrderStatus::NEW); + + $product = new Product(); + $product->setId(1); + $product->setName('テスト商品'); + $product->setDescriptionDetail('カスタム商品: テスト [セッション: abc12345]'); + + $productClass = new ProductClass(); + $productClass->setId(10); + $productClass->setVisible(true); + $productClass->setStock(1); + $productClass->setStockUnlimited(false); + $productClass->setProduct($product); + $product->addProductClass($productClass); + + $orderItem = new OrderItem(); + $orderItem->setProduct($product); + $orderItem->setProductClass($productClass); + + $order = new Order(); + $order->setId(100); + $order->setOrderStatus($orderStatus); + $order->getOrderItems()->add($orderItem); + + $this->entityManager->shouldReceive('persist')->with($productClass)->once(); + $this->entityManager->shouldReceive('persist')->with($product)->once(); + $this->entityManager->shouldReceive('flush')->once(); + + $event = new EventArgs(['Order' => $order]); + $this->listener->onShoppingComplete($event); + + $this->assertFalse($productClass->isVisible()); + $this->assertFalse($productClass->isStockUnlimited()); + $this->assertSame(0, $productClass->getStock()); + $this->assertStringContainsString('[購入済み]', $product->getName()); + $this->assertStringContainsString('購入済み', $product->getDescriptionDetail()); + } + + public function testNonCustomProductIsNotDisabled(): void + { + $orderStatus = new OrderStatus(); + $orderStatus->setId(OrderStatus::NEW); + + $product = new Product(); + $product->setId(2); + $product->setName('通常商品'); + $product->setDescriptionDetail('通常の商品説明文'); + + $productClass = new ProductClass(); + $productClass->setVisible(true); + $productClass->setProduct($product); + $product->addProductClass($productClass); + + $orderItem = new OrderItem(); + $orderItem->setProduct($product); + + $order = new Order(); + $order->setId(101); + $order->setOrderStatus($orderStatus); + $order->getOrderItems()->add($orderItem); + + // persist/flushは呼ばれない(通常商品は変更されない) + $this->entityManager->shouldNotReceive('persist'); + $this->entityManager->shouldNotReceive('flush'); + + $event = new EventArgs(['Order' => $order]); + $this->listener->onShoppingComplete($event); + + $this->assertTrue($productClass->isVisible()); + $this->assertStringNotContainsString('[購入済み]', $product->getName()); + } + + public function testAllProductClassesAreDisabledForCustomProduct(): void + { + $orderStatus = new OrderStatus(); + $orderStatus->setId(OrderStatus::NEW); + + $product = new Product(); + $product->setId(3); + $product->setName('マルチクラス商品'); + $product->setDescriptionDetail('カスタム商品: マルチ [セッション: xyz99999]'); + + $productClass1 = new ProductClass(); + $productClass1->setId(21); + $productClass1->setVisible(true); + $productClass1->setStock(1); + $productClass1->setProduct($product); + $product->addProductClass($productClass1); + + $productClass2 = new ProductClass(); + $productClass2->setId(22); + $productClass2->setVisible(true); + $productClass2->setStock(1); + $productClass2->setProduct($product); + $product->addProductClass($productClass2); + + $orderItem = new OrderItem(); + $orderItem->setProduct($product); + + $order = new Order(); + $order->setId(102); + $order->setOrderStatus($orderStatus); + $order->getOrderItems()->add($orderItem); + + // 2 productClasses + 1 product = 3回persist + $this->entityManager->shouldReceive('persist')->times(3); + $this->entityManager->shouldReceive('flush')->once(); + + $event = new EventArgs(['Order' => $order]); + $this->listener->onShoppingComplete($event); + + $this->assertFalse($productClass1->isVisible()); + $this->assertFalse($productClass2->isVisible()); + $this->assertSame(0, $productClass1->getStock()); + $this->assertSame(0, $productClass2->getStock()); + } + + public function testOrderItemWithNullProductIsSkipped(): void + { + $orderStatus = new OrderStatus(); + $orderStatus->setId(OrderStatus::NEW); + + $orderItem = new OrderItem(); + // productをセットしない(null) + + $order = new Order(); + $order->setId(103); + $order->setOrderStatus($orderStatus); + $order->getOrderItems()->add($orderItem); + + $this->entityManager->shouldNotReceive('flush'); + + $event = new EventArgs(['Order' => $order]); + $this->listener->onShoppingComplete($event); + $this->assertTrue(true); // 例外なく完了 + } + + public function testAlreadyDisabledProductIsNotDoubleMarked(): void + { + $orderStatus = new OrderStatus(); + $orderStatus->setId(OrderStatus::NEW); + + $product = new Product(); + $product->setId(4); + $product->setName('テスト商品 [購入済み]'); // already marked + $product->setDescriptionDetail('カスタム商品: テスト [セッション: abc12345] ※この商品は購入済みです。'); + + $productClass = new ProductClass(); + $productClass->setId(30); + $productClass->setVisible(false); + $productClass->setStock(0); + $productClass->setProduct($product); + $product->addProductClass($productClass); + + $orderItem = new OrderItem(); + $orderItem->setProduct($product); + + $order = new Order(); + $order->setId(104); + $order->setOrderStatus($orderStatus); + $order->getOrderItems()->add($orderItem); + + // ProductClassはpersistされるが、商品名は変更されない + $this->entityManager->shouldReceive('persist')->with($productClass)->once(); + $this->entityManager->shouldReceive('flush')->once(); + + $event = new EventArgs(['Order' => $order]); + $this->listener->onShoppingComplete($event); + + // 商品名に[購入済み]が2重につかないことを確認 + $this->assertSame('テスト商品 [購入済み]', $product->getName()); + } +} diff --git a/Tests/Unit/Repository/ConfigRepositoryTest.php b/Tests/Unit/Repository/ConfigRepositoryTest.php new file mode 100644 index 0000000..f12cb44 --- /dev/null +++ b/Tests/Unit/Repository/ConfigRepositoryTest.php @@ -0,0 +1,51 @@ +assertSame('customer_input', $config->getProductNameDisplayType()); + $this->assertNull($config->getPredefinedProductName()); + $this->assertNull($config->getPageLayout()); + $this->assertNull($config->getPageTitle()); + $this->assertNull($config->getPageDescription()); + } + + public function testConfigCanBeConfiguredForPredefinedName(): void + { + $config = new Config(); + $config->setProductNameDisplayType('predefined_name'); + $config->setPredefinedProductName('特別カスタム商品'); + $config->setPageLayout(2); + $config->setPageTitle('ご注文フォーム'); + $config->setPageDescription('お好みの金額でご注文いただけます。'); + + $this->assertSame('predefined_name', $config->getProductNameDisplayType()); + $this->assertSame('特別カスタム商品', $config->getPredefinedProductName()); + $this->assertSame(2, $config->getPageLayout()); + $this->assertSame('ご注文フォーム', $config->getPageTitle()); + $this->assertSame('お好みの金額でご注文いただけます。', $config->getPageDescription()); + } + + public function testConfigDisplayTypeCanBeSwitched(): void + { + $config = new Config(); + + $config->setProductNameDisplayType('customer_input'); + $this->assertSame('customer_input', $config->getProductNameDisplayType()); + + $config->setProductNameDisplayType('predefined_name'); + $this->assertSame('predefined_name', $config->getProductNameDisplayType()); + } +} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php new file mode 100644 index 0000000..e6541a5 --- /dev/null +++ b/Tests/bootstrap.php @@ -0,0 +1,14 @@ +=7.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6.17", + "symplify/easy-coding-standard": "^12.1.14" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2024-05-16T03:13:13+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.63", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "33198268dad71e926626b618f3ec3966661e4d90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/33198268dad71e926626b618f3ec3966661e4d90", + "reference": "33198268dad71e926626b618f3ec3966661e4d90", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.1", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.63" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2026-01-27T05:48:37+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-01-24T09:25:16+00:00" + }, + { + "name": "sebastian/complexity", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" + }, + { + "name": "sebastian/environment", + "version": "6.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:09:11+00:00" + }, + { + "name": "sebastian/global-state", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/47e34210757a2f37a97dcd207d032e1b01e64c7a", + "reference": "47e34210757a2f37a97dcd207d032e1b01e64c7a", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:50:56+00:00" + }, + { + "name": "sebastian/type", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:10:45+00:00" + }, + { + "name": "sebastian/version", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-07T11:34:05+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": {}, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..1f77eea --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + Tests + + + + + Controller + EventListener + Entity + Repository + + +