From 0cdb97862f6e4343c54959bafa84dc17df4cbf88 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 23:20:18 +0900 Subject: [PATCH] =?UTF-8?q?fix(ecommerce):=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EB=8B=B4=EA=B8=B0=C2=B7=EB=B0=94=EB=A1=9C=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4=20=EC=98=B5=EC=85=98=20=EC=8B=9D=EB=B3=84=EC=9D=84=20?= =?UTF-8?q?product=5Foption=5Fid=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가 문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값 텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를 전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다. 추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음. --- .../CHANGELOG.md | 6 ++ .../backend/ja/validation.php | 1 - .../language-pack.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 4 + .../sirsoft-ecommerce/docs/api/checkout.md | 2 +- .../Requests/Public/BulkAddToCartRequest.php | 3 +- .../Http/Requests/Public/CheckoutRequest.php | 3 +- .../src/Services/CartService.php | 16 ++-- .../src/Services/TempOrderService.php | 18 ++--- .../src/lang/en/validation.php | 1 - .../src/lang/ko/validation.php | 1 - .../Controllers/Public/CartControllerTest.php | 54 ++++++++++--- templates/_bundled/sirsoft-basic/CHANGELOG.md | 10 +++ .../partials/shop/_product_purchase_card.json | 2 +- .../partials/shop/detail/_purchase_card.json | 4 +- .../_bundled/sirsoft-basic/package-lock.json | 4 +- templates/_bundled/sirsoft-basic/package.json | 2 +- .../shop-buy-now-direct-checkout.test.tsx | 12 +++ .../layouts/shopAdditionalOptions.test.tsx | 6 ++ .../_bundled/sirsoft-basic/template.json | 4 +- .../specs/shop-cart-option-id.spec.ts | 78 +++++++++++++++++++ 21 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 1b41679b..25e8d62d 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-19 + +### Removed + +- 더 이상 사용하지 않는 장바구니 옵션 조합 검증 문구의 일본어 번역을 정리했습니다. + ## [1.0.4] - 2026-07-16 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 602d995b..e116971f 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -845,7 +845,6 @@ return [ 'item_not_found' => 'カート商品が見つかりません。', 'items_required' => 'カートに追加する商品を選択してください。', 'items_min' => '最低1つ以上の商品を選択してください。', - 'option_values_not_found' => '該当するオプション組み合わせが見つかりません。', ], 'wishlist' => [ 'product_id_required' => '商品を選択してください。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 180054e3..b5d6bd1c 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index d0d8f871..a8c94972 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -16,6 +16,10 @@ - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. +### Fixed + +- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다. + ## [1.0.4] - 2026-07-16 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index 93186507..e2141763 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -108,7 +108,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | item_ids | body | array | 아니오 | min 1 | item 식별자 배열 | -| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) | +| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/product_option_id/quantity, item_ids와 택일) | | coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 | | use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 | diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php index 1b832cf8..aaf85218 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php @@ -34,7 +34,8 @@ class BulkAddToCartRequest extends FormRequest $rules = [ 'product_id' => ['required', 'integer', Rule::exists(Product::class, 'id')], 'items' => 'required|array|min:1', - 'items.*.option_values' => 'nullable|array', + // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석 + 'items.*.product_option_id' => ['nullable', 'integer'], 'items.*.quantity' => 'required|integer|min:1|max:9999', // 추가옵션 선택 (서버에서 value_id 기준 가격 재조회·검증 — 클라 가격 신뢰 금지) 'items.*.additional_option_selections' => 'nullable|array', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php index 85efdc9c..1869d730 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php @@ -39,7 +39,8 @@ class CheckoutRequest extends FormRequest 'item_ids.*' => ['integer', Rule::exists(Cart::class, 'id')], 'direct_items' => [Rule::requiredIf($isDirect), 'array', 'min:1'], 'direct_items.*.product_id' => ['required_with:direct_items', 'integer'], - 'direct_items.*.option_values' => ['nullable', 'array'], + // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석 + 'direct_items.*.product_option_id' => ['nullable', 'integer'], 'direct_items.*.quantity' => ['required_with:direct_items', 'integer', 'min:1'], // 바로구매 추가옵션 선택 (서버에서 value_id 기준 검증/가격 재조회) 'direct_items.*.additional_option_selections' => ['nullable', 'array'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php index aab1854e..f8935ce1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php @@ -247,9 +247,9 @@ class CartService * 장바구니 일괄 담기 * * 하나의 상품에 대해 여러 옵션 조합을 한 번에 장바구니에 담습니다. - * option_values가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다. + * product_option_id가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다. * - * @param array $data {product_id, items: [{option_values?, quantity}], user_id?, cart_key?} + * @param array $data {product_id, items: [{product_option_id?, quantity}], user_id?, cart_key?} * @return array{items: array, cart_count: int} 추가된 아이템 목록과 장바구니 총 수량 */ public function bulkAddToCart(array $data): array @@ -272,17 +272,15 @@ class CartService $addedItems = []; foreach ($data['items'] as $item) { - $optionValues = $item['option_values'] ?? null; + $requestedOptionId = $item['product_option_id'] ?? null; $quantity = $item['quantity'] ?? 1; - // option_values로 product_option_id 조회 (로케일 기준 값으로 매칭) - if (! empty($optionValues)) { - $matchedOption = $productOptions->first(function ($option) use ($optionValues) { - return $option->getLocalizedOptionValues() == $optionValues; - }); + // 옵션 ID 기반 식별. 상품 소속 옵션 집합 내에서만 조회해 임의 옵션 주입을 차단 + if (! empty($requestedOptionId)) { + $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found')); + throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); } $productOptionId = $matchedOption->id; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php index d5a30d44..b9b94ccf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php @@ -169,7 +169,7 @@ class TempOrderService * 생성합니다. 판매상태·재고·구매수량 한도·구매대상제한 검증은 장바구니 담기와 동일하게 * 적용하되, 구매수량 한도는 장바구니 기존 수량과 합산하지 않고 이번 선택 수량만으로 판정합니다. * - * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}] + * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}] * @param int|null $userId 회원 ID * @param string|null $cartKey 비회원 장바구니 키 * @param int $usePoints 사용할 마일리지 @@ -196,10 +196,10 @@ class TempOrderService /** * 직접 항목 배열을 미저장 Cart 모델 컬렉션으로 변환합니다. * - * option_values 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를 + * product_option_id 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를 * set 한 미저장 Cart 모델을 만들어 기존 검증/계산/직렬화 로직을 그대로 재사용합니다. * - * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}] + * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}] * @param int|null $userId 회원 ID * @param string|null $cartKey 비회원 장바구니 키 * @return Collection 미저장 Cart 컬렉션 @@ -212,19 +212,17 @@ class TempOrderService foreach ($items as $item) { $productId = (int) ($item['product_id'] ?? 0); - $optionValues = $item['option_values'] ?? null; + $requestedOptionId = $item['product_option_id'] ?? null; $quantity = max(1, (int) ($item['quantity'] ?? 1)); - // 상품 옵션 매칭 (option_values 기준, 없으면 기본 옵션) + // 상품 옵션 매칭 (옵션 ID 기준, 없으면 기본 옵션) $productOptions = $this->productOptionRepository->getByProductId($productId); - if (! empty($optionValues)) { - $matchedOption = $productOptions->first( - fn ($option) => $option->getLocalizedOptionValues() == $optionValues - ); + if (! empty($requestedOptionId)) { + $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found')); + throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); } $optionId = $matchedOption->id; diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index bc5b49c7..b542728d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -942,7 +942,6 @@ return [ 'quantity_max' => 'Quantity cannot exceed 9,999.', 'items_required' => 'Please select items to add to cart.', 'items_min' => 'Please select at least one item.', - 'option_values_not_found' => 'Matching option combination not found.', ], // Wishlist validation messages diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 94434408..c77b55f3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -942,7 +942,6 @@ return [ 'quantity_max' => '수량은 최대 9,999개까지 가능합니다.', 'items_required' => '장바구니에 담을 상품을 선택해주세요.', 'items_min' => '최소 1개 이상의 상품을 선택해주세요.', - 'option_values_not_found' => '해당 옵션 조합을 찾을 수 없습니다.', ], // 찜 검증 메시지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php index b1067cd4..33bbf29c 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php @@ -7,6 +7,7 @@ use Modules\Sirsoft\Ecommerce\Database\Factories\CartFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory; use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum; +use Modules\Sirsoft\Ecommerce\Models\Cart; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy; @@ -135,7 +136,7 @@ class CartControllerTest extends ModuleTestCase $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $data['option']->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey])->assertStatus(201); @@ -162,7 +163,7 @@ class CartControllerTest extends ModuleTestCase $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $data['option']->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey])->assertStatus(201); @@ -221,7 +222,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -274,7 +275,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -307,7 +308,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -355,8 +356,8 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => ['색상' => '빨강', '사이즈' => 'L'], 'quantity' => 2], - ['option_values' => ['색상' => '파랑', '사이즈' => 'M'], 'quantity' => 1], + ['product_option_id' => $option1->id, 'quantity' => 2], + ['product_option_id' => $option2->id, 'quantity' => 1], ], ], [ 'X-Cart-Key' => $cartKey, @@ -384,7 +385,7 @@ class CartControllerTest extends ModuleTestCase $data = $this->createProductWithOption(); $cartKey = 'ck_'.str_repeat('f', 32); - // When: option_values 없이 담기 + // When: product_option_id 없이 담기 $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ @@ -399,6 +400,35 @@ class CartControllerTest extends ModuleTestCase $this->assertEquals(1, $response->json('data.cart_count')); } + /** + * 다른 상품에 속한 product_option_id 를 주입하면 담기가 거부되고 장바구니에 반영되지 않습니다. + * + * 회귀: 옵션 식별을 product_option_id 기반으로 전환하면서, 요청한 상품(product_id)의 옵션 + * 집합(getByProductId) 안에서만 옵션 ID 를 조회해야 한다. 타 상품 옵션 ID 주입 시 거부되어야 + * 서버가 임의 옵션(다른 상품/가격/재고)을 장바구니에 담는 위변조를 차단한다. + */ + public function test_bulk_add_rejects_option_id_from_another_product(): void + { + // Given: 각각 옵션을 가진 두 상품 + $target = $this->createProductWithOption(); + $foreign = $this->createProductWithOption(); + $cartKey = 'ck_'.str_repeat('h', 32); + + // When: target 상품에 foreign 상품의 옵션 ID 를 실어 담기 시도 + $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ + 'product_id' => $target['product']->id, + 'items' => [ + ['product_option_id' => $foreign['option']->id, 'quantity' => 1], + ], + ], [ + 'X-Cart-Key' => $cartKey, + ]); + + // Then: 담기 실패(비-201) + 장바구니에 어떤 행도 생성되지 않음 + $this->assertNotSame(201, $response->getStatusCode()); + $this->assertSame(0, Cart::where('cart_key', $cartKey)->count()); + } + /** * 일괄 담기 시 items가 비어있으면 422를 반환합니다. */ @@ -513,7 +543,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $option->id, 'quantity' => 1], ], ], [ 'X-Cart-Key' => $cartKey, @@ -553,7 +583,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 6], + ['product_option_id' => $option->id, 'quantity' => 6], ], ], [ 'X-Cart-Key' => $cartKey, @@ -591,7 +621,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $option->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey]); @@ -620,7 +650,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 5], + ['product_option_id' => $option->id, 'quantity' => 5], ], ], ['X-Cart-Key' => $cartKey]); diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 1d467234..b2f0ec41 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-19 + +### Fixed + +- 상품 상세에서 옵션을 선택해 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다. + +### Changed + +- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다. + ## [1.0.3] - 2026-07-16 ### Changed diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json index 03dd02b3..d65555c3 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json @@ -317,7 +317,7 @@ "method": "POST", "body": { "product_id": "{{product.data.id}}", - "items": "{{(_local.selectedItems ?? []).map(item => ({ option_values: item.optionValues, quantity: item.quantity }))}}" + "items": "{{(_local.selectedItems ?? []).map(item => ({ product_option_id: item.optionId, quantity: item.quantity }))}}" } }, "onSuccess": [ diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json index 1d42b53b..be8dbc39 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json @@ -507,7 +507,7 @@ "params": { "method": "POST", "headers": { "X-Cart-Key": "{{_global.cartKey}}" }, - "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { direct_items: (_local.selectedOptionItems ?? []).map(item => ({ product_id: product.data?.id, option_values: item.optionValues, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { direct_items: [{ product_id: product.data?.id, quantity: _local.noOptionQuantity ?? 1 }] }}}" + "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { direct_items: (_local.selectedOptionItems ?? []).map(item => ({ product_id: product.data?.id, product_option_id: item.optionId, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { direct_items: [{ product_id: product.data?.id, quantity: _local.noOptionQuantity ?? 1 }] }}}" }, "onSuccess": [ { @@ -591,7 +591,7 @@ "params": { "method": "POST", "headers": { "X-Cart-Key": "{{_global.cartKey}}" }, - "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ option_values: item.optionValues, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }}}" + "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ product_option_id: item.optionId, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }}}" }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "isAddingToCart": false, "selectedOptionItems": [], "currentSelection": {}, "noOptionQuantity": 1, "additionalOptions": {} } }, diff --git a/templates/_bundled/sirsoft-basic/package-lock.json b/templates/_bundled/sirsoft-basic/package-lock.json index f93b6349..61a77b8d 100644 --- a/templates/_bundled/sirsoft-basic/package-lock.json +++ b/templates/_bundled/sirsoft-basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-basic", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-basic", - "version": "1.0.2", + "version": "1.0.4", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-basic/package.json b/templates/_bundled/sirsoft-basic/package.json index 70ff321d..aaa5f04a 100644 --- a/templates/_bundled/sirsoft-basic/package.json +++ b/templates/_bundled/sirsoft-basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-basic", - "version": "1.0.3", + "version": "1.0.4", "description": "Gnuboard7 Basic User Template Components - Nexibase Style", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx index 43916af9..0fab6339 100644 --- a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx +++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx @@ -77,4 +77,16 @@ describe('_purchase_card.json - 바로 구매 장바구니 미경유', () => { }); expect(usesDirectItems).toBe(true); }); + + it('바로 구매 direct_items 가 옵션을 product_option_id(옵션 ID)로 전달한다 (값 기반 option_values 미사용)', () => { + const checkoutBody = buyNowApiCalls + .map((n) => n.params?.body) + .find((body) => typeof body === 'string' && body.includes('direct_items')) as + | string + | undefined; + expect(checkoutBody, '체크아웃 direct_items body 를 찾지 못함').toBeTruthy(); + // 옵션 식별은 옵션 ID 기반이어야 하며, 로케일 값 조합(option_values) 을 실어보내지 않는다 + expect(checkoutBody).toContain('product_option_id: item.optionId'); + expect(checkoutBody).not.toContain('option_values'); + }); }); diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx index d62a4c04..ecb49e7a 100644 --- a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx +++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx @@ -165,6 +165,12 @@ describe('_purchase_card.json 구조 — 블럭별 추가옵션', () => { expect(str).toContain('value_id'); }); + it('담기/구매 body가 메인 옵션을 product_option_id(옵션 ID)로 전달한다 (값 기반 option_values 미사용)', () => { + // 메인 옵션 식별은 옵션 ID 기반이어야 하며, 로케일 값 조합(option_values) 을 실어보내지 않는다 + expect(str).toContain('product_option_id: item.optionId'); + expect(str).not.toContain('option_values'); + }); + it('필수 추가옵션 미선택 차단 가드(additional_option_required)가 있다', () => { expect(str).toContain('shop.additional_option_required'); expect(str).toContain('is_required'); diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json index 54e1b21f..b4f2e053 100644 --- a/templates/_bundled/sirsoft-basic/template.json +++ b/templates/_bundled/sirsoft-basic/template.json @@ -5,7 +5,7 @@ "ko": "Basic", "en": "Basic" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "그누보드7 기본 사용자 템플릿", @@ -26,7 +26,7 @@ "dependencies": { "modules": { "sirsoft-board": ">=1.0.0", - "sirsoft-ecommerce": ">=1.0.2", + "sirsoft-ecommerce": ">=1.0.5", "sirsoft-page": ">=1.0.0" }, "plugins": { diff --git a/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts new file mode 100644 index 00000000..daf93426 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts @@ -0,0 +1,78 @@ +/** + * 상품상세 담기/바로구매가 메인 옵션을 product_option_id(옵션 ID)로 전송하는지 검증하는 유저 흐름 E2E. + * 템플릿 sirsoft-basic (유저 화면). (skeleton, placeholder) + * + * @scenario cart-add-option-id + * @effects add_to_cart_sends_product_option_id, + * buy_now_direct_items_sends_product_option_id, + * cart_reflects_exact_selected_option, + * foreign_product_option_id_rejected + * + * 배경: 담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가 + * getLocalizedOptionValues() 문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 + * 불일치나 관리자 옵션 값 텍스트 수정 시 매칭 실패(option_values_not_found)를 유발했다. + * 옵션 식별을 product_option_id 기반으로 전환해(클라가 이미 보유한 SelectedItem.optionId + * 전송), 서버는 요청 상품(product_id)의 옵션 집합 내에서 ID 로만 조회한다. + * + * e2e:allow 유저 담기/바로구매 흐름 — data-testid 보강 + 실 도메인 옵션 상품 시드 후 활성화한다. + * 현재 커버리지: + * (1) 레이아웃 payload 구조 — templates/_bundled/sirsoft-basic/src/__tests__/layouts/ + * shop-buy-now-direct-checkout.test.tsx ("direct_items 가 product_option_id: item.optionId + * 전송, option_values 미사용"), shopAdditionalOptions.test.tsx ("담기/구매 body 가 + * product_option_id 전송, option_values 미사용") green. + * (2) 백엔드 계약 — modules/_bundled/sirsoft-ecommerce PHPUnit: + * CartControllerTest("다른 상품 product_option_id 주입 거부", 일괄 담기/기본옵션), + * CheckoutControllerTest(direct_items), CartServiceTest/TempOrderServiceTest green. + * + * 본 spec 은 다음 사전 작업 완료 후 활성화한다: + * 1. 옵션 2조합(예: 빨강/L · 파랑/M) 보유 상품 시드 + PRODUCT_URL 확정 + * 2. _purchase_card 옵션 Select 에 data-testid="option-group-{idx}", 담기/바로구매 버튼 data-testid + * 3. _cart_item 옵션 라벨에 data-testid + * 4. PLAYWRIGHT_BASE_URL = 실 도메인, test.describe.skip → test.describe + * + * 매트릭스: + * T1 옵션 상품 담기 → POST /cart body.items[].product_option_id 가 선택 옵션 ID 와 일치, option_values 미포함 + * T2 바로구매 → POST /checkout direct_items[].product_option_id 전송 → 체크아웃 진입 + * T3 담은 뒤 장바구니에 "정확히 선택한 옵션"(빨강/L)이 반영 (로케일/텍스트 변경과 무관) + * T4 위변조: 타 상품 옵션 ID 주입 → 서버 거부(장바구니 미생성) + */ +import { test, expect } from '@playwright/test'; + +// 옵션 조합 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정) +const PRODUCT_URL = '/shop/products/{OPTION_PRODUCT_ID}'; + +test.describe.skip('유저 담기/바로구매 옵션 ID 전송 (placeholder — data-testid 보강 + 시드 후 활성화)', () => { + test('T1 옵션 상품 담기 요청이 product_option_id 를 전송한다 (option_values 미사용)', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ index: 1 }); + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/cart') && r.method() === 'POST'), + page.getByTestId('add-to-cart').click(), + ]); + const body = request.postDataJSON(); + expect(body.items?.[0]?.product_option_id).toEqual(expect.any(Number)); + expect(body.items?.[0]).not.toHaveProperty('option_values'); + }); + + test('T2 바로구매가 direct_items.product_option_id 로 체크아웃한다', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ index: 1 }); + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/checkout') && r.method() === 'POST'), + page.getByTestId('buy-now').click(), + ]); + const body = request.postDataJSON(); + expect(body.direct_items?.[0]?.product_option_id).toEqual(expect.any(Number)); + expect(body.direct_items?.[0]).not.toHaveProperty('option_values'); + }); + + test('T3 담은 뒤 장바구니에 정확히 선택한 옵션이 반영된다', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ label: /빨강/ }); + await page.getByTestId('add-to-cart').click(); + await page.goto('/shop/cart'); + await expect(page.getByTestId('cart-item').first()).toContainText(/빨강/); + }); +});