fix(ecommerce): 장바구니 담기·바로구매 옵션 식별을 product_option_id 기반으로 전환

담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가
문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값
텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를
전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다.
추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음.
This commit is contained in:
HeuJung
2026-07-19 23:20:18 +09:00
parent 8522245137
commit 0cdb97862f
21 changed files with 187 additions and 46 deletions
@@ -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
@@ -845,7 +845,6 @@ return [
'item_not_found' => 'カート商品が見つかりません。',
'items_required' => 'カートに追加する商品を選択してください。',
'items_min' => '最低1つ以上の商品を選択してください。',
'option_values_not_found' => '該当するオプション組み合わせが見つかりません。',
],
'wishlist' => [
'product_id_required' => '商品を選択してください。',
@@ -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",
@@ -16,6 +16,10 @@
- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
### Fixed
- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다.
## [1.0.4] - 2026-07-16
### Fixed
@@ -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 | 사용할 마일리지(적립금) 포인트 |
@@ -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',
@@ -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'],
@@ -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>, 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;
@@ -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<int, Cart> 미저장 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;
@@ -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
@@ -942,7 +942,6 @@ return [
'quantity_max' => '수량은 최대 9,999개까지 가능합니다.',
'items_required' => '장바구니에 담을 상품을 선택해주세요.',
'items_min' => '최소 1개 이상의 상품을 선택해주세요.',
'option_values_not_found' => '해당 옵션 조합을 찾을 수 없습니다.',
],
// 찜 검증 메시지
@@ -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]);
@@ -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
@@ -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": [
@@ -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": {} } },
+2 -2
View File
@@ -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",
@@ -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",
@@ -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');
});
});
@@ -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');
@@ -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": {
@@ -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(/빨강/);
});
});