From d675c8ac9fc09fa60fd7c87c4180fb5343bef5f2 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Wed, 12 Aug 2026 16:12:25 +0900 Subject: [PATCH] =?UTF-8?q?fix(ecommerce,board):=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=EC=82=AC=EC=9A=A9=20=EB=9D=BC=EB=94=94?= =?UTF-8?q?=EC=98=A4=20boolean=20=EB=AC=B8=EC=9E=90=EC=97=B4=ED=99=94?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20422=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=8D=98=20=EA=B2=B0=ED=95=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 제보(gnuboard/g7 @lyg-kaban): 관리자 쿠폰 폼의 「타 쿠폰과 중복 사용」 라디오를 클릭하면 자동바인딩이 DOM 문자열 "true"/"false" 를 폼 상태에 그대로 저장해 서버 boolean 규칙에서 422 로 저장이 실패했다. 표시 계층은 느슨 비교로 정상이라 저장 시점에야 드러나는 결함. - 레이아웃: is_combinable 라디오 autoBinding 해제 + String 표시 바인딩 + change 액션 === 'true' boolean 캐스팅 - 서버: Store/UpdateCouponRequest prepareForValidation 에서 문자열 표기 수용 boolean 정규화 (해석 불가값은 유지해 boolean 규칙이 422 처리) - 게시판 is_secret 동일 잠재 위험 서버 방어 + User Store/UpdatePostRequest 의 Board 직접 조회 4곳을 Repository 경유로 교체 (board 1.0.4) - 회귀: PHPUnit 11건(RED 선행) + 레이아웃 쓰기경로 타입 프로브 + E2E 왕복 spec + 시나리오 매니페스트(cases=32) - 재발 차단: ·AGENTS.md 금지 패턴 + audit 룰 layout-boolean-option-autobinding 신설(전수 0건) + 형제 룰 4종 extensions/** glob 누락 보강 + 트러블슈팅 사례 8 + 회귀 테스트 --- AGENTS.md | 1 + modules/_bundled/sirsoft-board/CHANGELOG.md | 6 + modules/_bundled/sirsoft-board/composer.json | 2 +- .../_bundled/sirsoft-board/docs/api/boards.md | 2 + modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-board/package-lock.json | 4 +- modules/_bundled/sirsoft-board/package.json | 2 +- .../Http/Requests/User/StorePostRequest.php | 28 ++- .../Http/Requests/User/UpdatePostRequest.php | 28 ++- .../PostSecretBooleanNormalizationTest.php | 187 +++++++++++++++ .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../docs/api/promotion-coupons.md | 95 +++++++- .../couponCombinableWritePath.test.tsx | 217 ++++++++++++++++++ .../layouts/couponFormLayouts.test.tsx | 21 ++ .../_partial_usage_conditions.json | 13 ++ .../Requests/Admin/StoreCouponRequest.php | 8 + .../Requests/Admin/UpdateCouponRequest.php | 8 + .../Admin/CouponCombinableBooleanTest.php | 210 +++++++++++++++++ .../Playwright/fixtures/ecommerce-auth.ts | 12 + .../admin/promotion-coupon-combinable.spec.ts | 134 +++++++++++ .../issue97-coupon-combinable-boolean.yaml | 47 ++++ .../troubleshooting-components.test.ts | 50 ++++ 22 files changed, 1060 insertions(+), 18 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponCombinableWritePath.test.tsx create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponCombinableBooleanTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/promotion-coupon-combinable.spec.ts create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue97-coupon-combinable-boolean.yaml diff --git a/AGENTS.md b/AGENTS.md index d4787f70..91a22c98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,6 +260,7 @@ | `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 | | Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) | | `options={{options}}` | `options={{options ?? []}}` (fallback) | +| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) | Icon 은 `` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측). diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 61e275d5..4c3a2461 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/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.4] - 2026-08-12 + +### Changed + +- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다. + ## [1.0.3] - 2026-08-10 ### Security diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index 02e719ba..fb973132 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-board", "description": "Board module for Gnuboard7", "type": "library", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md index 55f2117b..3b5f4e6a 100644 --- a/modules/_bundled/sirsoft-board/docs/api/boards.md +++ b/modules/_bundled/sirsoft-board/docs/api/boards.md @@ -2961,6 +2961,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | slug | path | string | 예 | — | 대상 리소스의 slug (URL 친화 식별자) | +| is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.store_validation_rules`). @@ -3628,6 +3629,7 @@ HTTP/1.1 200 | id | path | string | 예 | — | 대상 리소스의 식별자 | | attachment_ids | body | integer[] | 아니오 | — | 미리 업로드해 둔 첨부 ID 목록. 게시글에 연결된다 (관리자 수정 경로와 동일) | | temp_key | body | string | 아니오 | 최대 64자 | 임시 업로드 묶음 키. 해당 묶음의 첨부가 게시글로 이동·연결된다 | +| is_secret | body | boolean | 아니오 | — | 비밀글 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) | > 이 엔드포인트는 확장이 파라미터를 추가할 수 있습니다 (`sirsoft-board.user_post.update_validation_rules`). diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 14ab4275..d8cd12d8 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index 8c3338ef..0b905c91 100644 --- a/modules/_bundled/sirsoft-board/package-lock.json +++ b/modules/_bundled/sirsoft-board/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-board", - "version": "1.0.3", + "version": "1.0.4", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index cf979a28..76312ed7 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.3", + "version": "1.0.4", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php index 7c53f6a8..a989ad79 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/StorePostRequest.php @@ -11,8 +11,8 @@ use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ResolvesAllowedExtensions; use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; -use Modules\Sirsoft\Board\Models\Board; use Modules\Sirsoft\Board\Models\Post; +use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; use Modules\Sirsoft\Board\Rules\CooldownRule; use Modules\Sirsoft\Board\Rules\ParentPostValidationRule; @@ -40,6 +40,28 @@ class StorePostRequest extends FormRequest return true; } + /** + * 검증 전 입력값을 정규화합니다. + * + * 비밀글 여부를 문자열 "true"/"false" 로 보내는 클라이언트도 수용하도록 + * boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리) + */ + protected function prepareForValidation(): void + { + $merge = []; + + if ($this->has('is_secret')) { + $normalized = filter_var($this->input('is_secret'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized !== null) { + $merge['is_secret'] = $normalized; + } + } + + if ($merge) { + $this->merge($merge); + } + } + /** * 요청에 적용할 검증 규칙 * @@ -48,7 +70,7 @@ class StorePostRequest extends FormRequest public function rules(): array { $slug = $this->route('slug'); - $board = Board::where('slug', $slug)->first(); + $board = app(BoardRepositoryInterface::class)->findBySlug((string) $slug); // 게시판이 없으면 모듈 환경설정 기본값 사용 $defaults = g7_module_settings('sirsoft-board', 'basic_defaults', []); @@ -225,7 +247,7 @@ class StorePostRequest extends FormRequest public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator) { - $board = Board::where('slug', $this->route('slug'))->first(); + $board = app(BoardRepositoryInterface::class)->findBySlug((string) $this->route('slug')); $this->validateAttachmentTotal($validator, $board); }); } diff --git a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php index ce75e6ec..ab3c33cd 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php +++ b/modules/_bundled/sirsoft-board/src/Http/Requests/User/UpdatePostRequest.php @@ -8,7 +8,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; use Modules\Sirsoft\Board\Enums\PostStatus; use Modules\Sirsoft\Board\Http\Requests\Concerns\ValidatesAttachmentCount; -use Modules\Sirsoft\Board\Models\Board; +use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Rules\BlockedKeywordsRule; /** @@ -33,6 +33,28 @@ class UpdatePostRequest extends FormRequest return true; } + /** + * 검증 전 입력값을 정규화합니다. + * + * 비밀글 여부를 문자열 "true"/"false" 로 보내는 클라이언트도 수용하도록 + * boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리) + */ + protected function prepareForValidation(): void + { + $merge = []; + + if ($this->has('is_secret')) { + $normalized = filter_var($this->input('is_secret'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized !== null) { + $merge['is_secret'] = $normalized; + } + } + + if ($merge) { + $this->merge($merge); + } + } + /** * 요청에 적용할 검증 규칙 * @@ -41,7 +63,7 @@ class UpdatePostRequest extends FormRequest public function rules(): array { $slug = $this->route('slug'); - $board = Board::where('slug', $slug)->first(); + $board = app(BoardRepositoryInterface::class)->findBySlug((string) $slug); if (! $board) { return []; @@ -134,7 +156,7 @@ class UpdatePostRequest extends FormRequest public function withValidator(Validator $validator): void { $validator->after(function (Validator $validator) { - $board = Board::where('slug', $this->route('slug'))->first(); + $board = app(BoardRepositoryInterface::class)->findBySlug((string) $this->route('slug')); $postId = $this->route('id'); $this->validateAttachmentTotal($validator, $board, is_numeric($postId) ? (int) $postId : null); }); diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php new file mode 100644 index 00000000..f7770fb3 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostSecretBooleanNormalizationTest.php @@ -0,0 +1,187 @@ + $slug, + 'name' => ['ko' => '비밀글 boolean 테스트 게시판', 'en' => 'Secret Boolean Test Board'], + 'is_active' => true, + 'secret_mode' => 'enabled', + 'blocked_keywords' => [], + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + $this->grantUserRolePermissions(); + + $this->memberUser = User::factory()->create(['email' => 'secret-bool@test.com']); + $userRole = Role::where('identifier', 'user')->first(); + $this->memberUser->roles()->attach($userRole->id); + } + + /** + * 수용해야 하는 boolean 표기 4종(문자열)과 기대 저장값 + * + * @return array [입력값, 기대 boolean] + */ + private function acceptedNotations(): array + { + return [ + "문자열 'true'" => ['true', true], + "문자열 'false'" => ['false', false], + "문자열 '1'" => ['1', true], + "문자열 '0'" => ['0', false], + ]; + } + + /** + * 작성: 문자열 boolean 표기 4종 모두 201 + DB boolean 저장 + */ + public function test_store_accepts_string_boolean_notations(): void + { + foreach ($this->acceptedNotations() as $label => [$input, $expected]) { + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => "비밀글 표기 테스트 {$label}", + 'content' => '비밀글 boolean 정규화 검증용 내용입니다.', + 'is_secret' => $input, + ]); + + $response->assertStatus(201); + + $postId = $response->json('data.id'); + $stored = (bool) DB::table('board_posts')->where('id', $postId)->value('is_secret'); + $this->assertSame( + $expected, + $stored, + "작성 {$label} 입력이 boolean 으로 저장되어야 합니다." + ); + } + } + + /** + * 수정: 문자열 boolean 표기 4종 모두 200 + DB boolean 저장 + */ + public function test_update_accepts_string_boolean_notations(): void + { + $createResponse = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => '비밀글 수정 표기 테스트', + 'content' => '비밀글 boolean 정규화 수정 검증용 내용입니다.', + 'is_secret' => false, + ]); + $createResponse->assertStatus(201); + $postId = $createResponse->json('data.id'); + + foreach ($this->acceptedNotations() as $label => [$input, $expected]) { + $response = $this->actingAs($this->memberUser) + ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [ + 'is_secret' => $input, + ]); + + $response->assertStatus(200); + + $stored = (bool) DB::table('board_posts')->where('id', $postId)->value('is_secret'); + $this->assertSame( + $expected, + $stored, + "수정 {$label} 입력이 boolean 으로 저장되어야 합니다." + ); + } + } + + /** + * 작성: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리 + */ + public function test_store_rejects_unparsable_value_with_422(): void + { + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => '비밀글 해석불가 테스트', + 'content' => '비밀글 boolean 해석 불가값 검증용 내용입니다.', + 'is_secret' => 'abc', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['is_secret']); + } + + /** + * 수정: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리 + */ + public function test_update_rejects_unparsable_value_with_422(): void + { + $createResponse = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => '비밀글 수정 해석불가 테스트', + 'content' => '비밀글 boolean 수정 해석 불가값 검증용 내용입니다.', + 'is_secret' => true, + ]); + $createResponse->assertStatus(201); + $postId = $createResponse->json('data.id'); + + $response = $this->actingAs($this->memberUser) + ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$postId}", [ + 'is_secret' => 'abc', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['is_secret']); + + // 실패한 요청이 저장값을 건드리지 않아야 한다 + $this->assertSame(1, (int) DB::table('board_posts')->where('id', $postId)->value('is_secret')); + } + + /** + * 작성: is_secret 미전송이면 정규화가 개입하지 않고 그대로 통과 (has 가드) + */ + public function test_store_without_is_secret_passes(): void + { + $response = $this->actingAs($this->memberUser) + ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [ + 'title' => '비밀글 미전송 테스트', + 'content' => '비밀글 필드 미전송 검증용 내용입니다.', + ]); + + $response->assertStatus(201); + + $postId = $response->json('data.id'); + $this->assertSame(0, (int) DB::table('board_posts')->where('id', $postId)->value('is_secret')); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 66620ffd..1a0210b0 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -12,6 +12,7 @@ ### Fixed +- 관리자 쿠폰 등록·수정에서 「타 쿠폰과 중복 사용」을 선택하면 `is combinable 필드는 true 또는 false여야 합니다.` 오류로 저장이 실패하던 문제를 수정했습니다. (#97 @lyg-kaban 님께서 제보해주셨습니다.) - 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.) - 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.) - 관리자 엑셀 다운로드 관리의 다운로드 이력 모달에서, 이력 항목이 아직 선택되지 않은 순간에 파일 정보·상태 배지가 비정상적으로 처리되던 문제를 보완했습니다. 항목을 선택한 뒤의 표시는 종전과 동일합니다. diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md index 9ae14fea..d639110f 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/promotion-coupons.md @@ -215,7 +215,7 @@ HTTP/1.1 200 | valid_to | body | date | 아니오 | — | 유효기간 종료일 (valid_type=period 시 필수, valid_from 이후) | | issue_from | body | date | 아니오 | — | 발급기간 시작 일시 (미입력 시 상시발급) | | issue_to | body | date | 아니오 | — | 발급기간 종료 일시 (issue_from 이후) | -| is_combinable | body | boolean | 아니오 | — | combinable 여부 | +| is_combinable | body | boolean | 아니오 | — | 다른 쿠폰과 중복 사용 가능 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) | | target_scope | body | string | 아니오 | `all`, `products`, `categories` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | | products | body | array | 아니오 | — | 적용 상품 목록 (`target_scope=products`), 항목별 `{id, type: include\|exclude}` | | categories | body | array | 아니오 | — | 적용 카테고리 목록 (`target_scope=categories`), 항목별 `{id, type: include\|exclude}` | @@ -515,11 +515,25 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| coupon_id | integer | `52` | 삭제된 쿠폰(정의)의 ID | **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰이 삭제되었습니다.", + "data": { + "coupon_id": 52 + } +} +``` **에러 응답** @@ -557,11 +571,36 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) - +_단건 응답: `data` 객체의 필드 (`CouponResource`) — 생성(`POST /admin/promotion-coupons`) 201 응답의 `data` 와 동일 스키마이므로 필드 표는 그 절을 참조합니다._ **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 정보를 조회했습니다.", + "data": { + "id": 2, + "name": "신규가입 쿠폰", + "discount_type": "fixed", + "discount_value": 5000, + "issue_status": "issuing", + "is_combinable": false, + "target_scope": "all", + "abilities": { + "can_create": true, + "can_update": true, + "can_delete": true + } + } +} +``` + +_예시는 대표 필드만 발췌한 축약본입니다. 전체 필드는 생성 201 응답 예시와 동일합니다._ **에러 응답** @@ -605,7 +644,7 @@ Authorization: Bearer {YOUR_TOKEN} | valid_to | body | date | 아니오 | — | 유효기간 종료일 (valid_type=period 시 필수, valid_from 이후) | | issue_from | body | date | 아니오 | — | 발급기간 시작 일시 (미입력 시 상시발급) | | issue_to | body | date | 아니오 | — | 발급기간 종료 일시 (issue_from 이후) | -| is_combinable | body | boolean | 아니오 | — | combinable 여부 | +| is_combinable | body | boolean | 아니오 | — | 다른 쿠폰과 중복 사용 가능 여부. 문자열 `"true"`/`"false"`/`"1"`/`"0"` 도 수용해 boolean 으로 정규화한다 (해석 불가값은 422) | | target_scope | body | string | 아니오 | `all`, `products`, `categories` | 적용 범위: all(전체 상품), products(특정 상품), categories(특정 카테고리) | | products | body | array | 아니오 | — | 적용 상품 목록 (`target_scope=products`), 항목별 `{id, type: include\|exclude}` | | categories | body | array | 아니오 | — | 적용 카테고리 목록 (`target_scope=categories`), 항목별 `{id, type: include\|exclude}` | @@ -951,7 +990,49 @@ _목록 응답: `data.data[]` 배열 항목의 필드 (`CouponIssueResource`) + **응답 예시** - +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "쿠폰 발급 내역을 조회했습니다.", + "data": { + "data": [ + { + "id": 1, + "coupon_id": 1, + "user_id": "a1e0a91a-fba6-491c-a53e-7285a5686857", + "coupon_code": "WELCOME2026", + "status": "available", + "status_label": "사용가능", + "status_badge_color": "blue", + "issued_at": "2026-07-08 10:50:11", + "expired_at": "2026-07-31 23:59:59", + "used_at": null, + "order_id": null, + "order_number": null, + "discount_amount": null, + "is_used": false, + "is_expired": false, + "is_usable": true, + "is_cancellable": true, + "user_name": "홍길동" + } + ], + "pagination": { + "current_page": 1, + "last_page": 1, + "per_page": 20, + "total": 1, + "from": 1, + "to": 1, + "has_more_pages": false + } + } +} +``` **에러 응답** diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponCombinableWritePath.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponCombinableWritePath.test.tsx new file mode 100644 index 00000000..dd5b9cc2 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponCombinableWritePath.test.tsx @@ -0,0 +1,217 @@ +/** + * @file couponCombinableWritePath.test.tsx + * @description 쿠폰 is_combinable 라디오 쓰기 경로 boolean 회귀 — 공개 이슈 #97 + * + * 결함: RadioGroup 이 `name` 자동바인딩만으로 폼에 묶이면 클릭 시 + * `e.target.value`(DOM 문자열 "true"/"false")가 그대로 폼 상태에 저장되어 + * 서버 `boolean` 규칙에서 422 가 된다. 표시 계층은 느슨 비교(String===String)로 + * 정상이라 저장 시점에야 드러난다. + * + * 정정: 레이아웃이 `autoBinding: false` + `value: String(...)` 표시 바인딩 + + * `change` 액션의 `=== 'true'` 캐스팅으로 쓰기 경로를 boolean 으로 고정한다. + * + * 실제 partial(_partial_usage_conditions.json)의 field_is_combinable 노드를 + * 실제 메인 레이아웃과 동일한 폼 컨텍스트(dataKey: "form" + debounce: 300)로 + * 렌더하고, 폼 상태의 타입은 레이아웃 바인딩 프로브(엄격 === 비교 삼항식)로 + * 관측한다 — 렌더러 내부 폼 상태는 테스트 하네스 getState() 로는 보이지 않는다. + * (수정 전에는 클릭 후 프로브가 string-false 라 RED) + * + * @vitest-environment jsdom + */ + +import React, { useState, useEffect } from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createLayoutTest, + createMockComponentRegistryWithBasics, + fireEvent, + waitFor, + screen, + type MockComponentRegistry, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +import usageConditionsPartial from '../../../layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json'; + +/** JSON 트리에서 id로 노드를 재귀 탐색 */ +function findById(node: any, id: string): any { + if (!node) return null; + if (node.id === id) return node; + if (Array.isArray(node.children)) { + for (const child of node.children) { + const found = findById(child, id); + if (found) return found; + } + } + return null; +} + +/** + * FormField 더블 — 라벨 + children 렌더 (구조 통과용) + */ +const TestFormField: React.FC<{ + label?: string; + error?: string; + children?: React.ReactNode; +}> = ({ label, error, children }) => ( +
+ {label && {label}} + {children} + {error && {error}} +
+); + +/** + * RadioGroup 더블 — 실제 sirsoft-admin_basic RadioGroup 의 핵심 동작 복제: + * - 내부 상태 + prop 동기화 + * - 표시: String(option.value) === String(value) 느슨 비교 (표시 계층 구제 동작 그대로) + * - 클릭: DOM ChangeEvent 를 onChange 로 그대로 전달 (e.target.value 는 문자열) + */ +const TestRadioGroup: React.FC<{ + name: string; + value?: string; + options: Array<{ value: string; label: string }>; + onChange?: (e: React.ChangeEvent) => void; + inline?: boolean; +}> = ({ name, value: valueProp, options, onChange }) => { + const [value, setValue] = useState(() => valueProp ?? ''); + + useEffect(() => { + setValue(valueProp ?? ''); + }, [valueProp]); + + const handleChange = (e: React.ChangeEvent) => { + setValue(e.target.value); + if (onChange) onChange(e); + }; + + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +}; + +/** + * 폼 상태 타입 프로브 — 엄격 === 비교로 boolean 과 문자열을 구분해 텍스트로 노출. + * 자동바인딩/액션의 쓰기 결과는 렌더러 내부 _local 에 반영되므로 + * 레이아웃 바인딩으로만 정확한 타입을 관측할 수 있다. + */ +const TYPE_PROBE_EXPR = + "{{_local.form?.is_combinable === true ? 'bool-true' : (_local.form?.is_combinable === false ? 'bool-false' : (_local.form?.is_combinable === 'true' ? 'string-true' : (_local.form?.is_combinable === 'false' ? 'string-false' : 'other')))}}"; + +/** + * 실제 partial 의 field_is_combinable 노드만 잘라 프로브 레이아웃 구성. + * 폼 컨텍스트는 실제 메인 레이아웃(admin_ecommerce_promotion_coupon_form.json + * #form_sections)과 동일하게 dataKey: "form" + debounce: 300 을 재현한다 — + * 자동바인딩 쓰기 경로는 이 컨텍스트가 있어야 발화한다. + */ +function buildProbeLayout() { + const fieldNode = findById(usageConditionsPartial, 'field_is_combinable'); + if (!fieldNode) throw new Error('field_is_combinable 노드를 찾지 못함'); + + return { + version: '1.0.0', + layout_name: 'test/coupon-combinable-write-path', + state: { + form: { + is_combinable: true, + }, + }, + components: [ + { + type: 'basic', + name: 'Div', + dataKey: 'form', + debounce: 300, + children: [ + fieldNode, + { + type: 'basic', + name: 'Span', + props: { 'data-testid': 'combinable-type-probe' }, + text: TYPE_PROBE_EXPR, + }, + ], + }, + ], + }; +} + +describe('쿠폰 is_combinable 쓰기 경로 (공개 #97)', () => { + let registry: MockComponentRegistry; + let testUtils: ReturnType; + + beforeEach(() => { + registry = createMockComponentRegistryWithBasics(); + registry.register('composite', 'FormField', TestFormField); + registry.register('composite', 'RadioGroup', TestRadioGroup); + + testUtils = createLayoutTest(buildProbeLayout(), { + componentRegistry: registry as any, + locale: 'ko', + }); + }); + + afterEach(() => { + testUtils.cleanup(); + vi.clearAllMocks(); + }); + + it('초기 상태: is_combinable 은 boolean true, "가능" 라디오 checked', async () => { + await testUtils.render(); + + expect(screen.getByTestId('combinable-type-probe').textContent).toBe('bool-true'); + + const radios = document.querySelectorAll('input[name=is_combinable]'); + expect(radios).toHaveLength(2); + expect(radios[0].checked).toBe(true); // value "true" (가능) + expect(radios[1].checked).toBe(false); // value "false" (불가능) + }); + + it('"불가능" 라디오 클릭 → 폼 상태가 boolean false 로 저장된다 (문자열 "false" 금지)', async () => { + await testUtils.render(); + + const radios = document.querySelectorAll('input[name=is_combinable]'); + fireEvent.click(radios[1]); + + // debounce 300ms 를 넘겨 최종 저장값의 타입을 판정한다 + await waitFor( + () => { + expect(screen.getByTestId('combinable-type-probe').textContent).toBe('bool-false'); + }, + { timeout: 2000 } + ); + }); + + it('"불가능" → "가능" 재클릭 → boolean true 복원', async () => { + await testUtils.render(); + + const radios = document.querySelectorAll('input[name=is_combinable]'); + fireEvent.click(radios[1]); + await waitFor( + () => { + expect(screen.getByTestId('combinable-type-probe').textContent).toBe('bool-false'); + }, + { timeout: 2000 } + ); + + fireEvent.click(radios[0]); + await waitFor( + () => { + expect(screen.getByTestId('combinable-type-probe').textContent).toBe('bool-true'); + }, + { timeout: 2000 } + ); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponFormLayouts.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponFormLayouts.test.tsx index a03622fc..962f9028 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponFormLayouts.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponFormLayouts.test.tsx @@ -564,6 +564,27 @@ describe('couponFormLayouts', () => { expect(radioGroup.props.options[1].value).toBe('false'); }); + it('쿠폰 중복 사용 RadioGroup: 쓰기 경로 boolean 캐스팅 (공개 #97)', () => { + const field = findById(usageConditionsPartial, 'field_is_combinable'); + const radioGroup = findByName(field, 'RadioGroup'); + + // 자동바인딩 value 경로는 e.target.value 문자열을 그대로 저장하므로 차단 + expect(radioGroup.props.autoBinding).toBe(false); + + // 표시 바인딩: 저장된 boolean 을 문자열로 변환해 옵션 값과 비교 + expect(radioGroup.props.value).toContain('String('); + expect(radioGroup.props.value).toContain('is_combinable'); + + // 쓰기 경로: change 액션이 === 'true' 캐스팅으로 boolean 저장 + const changeAction = (radioGroup.actions ?? []).find( + (a: any) => a.type === 'change' && a.handler === 'setState' + ); + expect(changeAction).toBeDefined(); + expect(changeAction.params['form.is_combinable']).toBe( + "{{$event.target.value === 'true'}}" + ); + }); + it('적용 대상 RadioGroup: all/products/categories', () => { const field = findById(usageConditionsPartial, 'field_target_scope'); expect(field).toBeDefined(); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json index 87e58a8a..ee3296c6 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_promotion_coupon_form/_partial_usage_conditions.json @@ -32,11 +32,14 @@ }, "children": [ { + "comment": "boolean 필드는 자동바인딩 금지 — e.target.value 문자열이 그대로 저장되어 서버 boolean 규칙에서 422 (공개 #97). 표시는 String() 변환, 쓰기는 change 액션의 === 'true' 캐스팅", "type": "composite", "name": "RadioGroup", "props": { "name": "is_combinable", "inline": true, + "autoBinding": false, + "value": "{{String(_local.form?.is_combinable ?? true)}}", "options": [ { "label": "$t:sirsoft-ecommerce.admin.promotion_coupon.form.option.combinable_yes", @@ -48,6 +51,16 @@ } ] }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.is_combinable": "{{$event.target.value === 'true'}}" + } + } + ], "responsive": { "mobile": { "props": { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php index 48d41ddb..9f81611c 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreCouponRequest.php @@ -72,6 +72,14 @@ class StoreCouponRequest extends FormRequest $merge['min_order_amount'] = 0; } + // 라디오가 문자열 "true"/"false" 를 보낼 수 있어 boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리) + if ($this->has('is_combinable')) { + $normalized = filter_var($this->input('is_combinable'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized !== null) { + $merge['is_combinable'] = $normalized; + } + } + if ($merge) { $this->merge($merge); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php index e786735f..a4dbaecb 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/UpdateCouponRequest.php @@ -72,6 +72,14 @@ class UpdateCouponRequest extends FormRequest $merge['min_order_amount'] = 0; } + // 라디오가 문자열 "true"/"false" 를 보낼 수 있어 boolean 으로 정규화합니다. (해석 불가값은 유지 → boolean 규칙이 422 처리) + if ($this->has('is_combinable')) { + $normalized = filter_var($this->input('is_combinable'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($normalized !== null) { + $merge['is_combinable'] = $normalized; + } + } + if ($merge) { $this->merge($merge); } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponCombinableBooleanTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponCombinableBooleanTest.php new file mode 100644 index 00000000..d598ec24 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponCombinableBooleanTest.php @@ -0,0 +1,210 @@ +setLocale('ko'); + + $this->adminUser = $this->createAdminUser([ + 'sirsoft-ecommerce.promotion-coupon.read', + 'sirsoft-ecommerce.promotion-coupon.create', + 'sirsoft-ecommerce.promotion-coupon.update', + ]); + } + + /** + * 쿠폰 생성 시 필요한 기본 데이터 + * + * @param array $overrides 오버라이드할 속성 + */ + private function validCouponData(array $overrides = []): array + { + return array_merge([ + 'name' => ['ko' => '중복사용 테스트 쿠폰', 'en' => 'Combinable Test Coupon'], + 'description' => ['ko' => '테스트 설명', 'en' => 'Test description'], + 'target_type' => CouponTargetType::PRODUCT_AMOUNT->value, + 'discount_type' => CouponDiscountType::FIXED->value, + 'discount_value' => 1000, + 'min_order_amount' => 5000, + 'issue_method' => CouponIssueMethod::DIRECT->value, + 'issue_condition' => CouponIssueCondition::MANUAL->value, + 'issue_status' => CouponIssueStatus::ISSUING->value, + 'per_user_limit' => 0, + 'valid_type' => 'period', + 'valid_from' => now()->format('Y-m-d'), + 'valid_to' => now()->addMonth()->format('Y-m-d'), + 'is_combinable' => true, + 'target_scope' => CouponTargetScope::ALL->value, + ], $overrides); + } + + /** + * 수용해야 하는 boolean 표기 6종과 기대 저장값 + * + * @return array [입력값, 기대 boolean] + */ + private function acceptedNotations(): array + { + return [ + "문자열 'true'" => ['true', true], + "문자열 'false'" => ['false', false], + "문자열 '1'" => ['1', true], + "문자열 '0'" => ['0', false], + 'boolean true' => [true, true], + 'boolean false' => [false, false], + ]; + } + + /** + * 생성: boolean 표기 6종 모두 201 + DB boolean 저장 + */ + public function test_store_accepts_boolean_notations_and_persists_boolean(): void + { + foreach ($this->acceptedNotations() as $label => [$input, $expected]) { + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $this->validCouponData([ + 'is_combinable' => $input, + ])); + + $response->assertStatus(201); + + $coupon = Coupon::find($response->json('data.id')); + $this->assertSame( + $expected, + $coupon->is_combinable, + "생성 {$label} 입력이 boolean {$this->boolLabel($expected)} 로 저장되어야 합니다." + ); + } + } + + /** + * 수정: boolean 표기 6종 모두 200 + DB boolean 저장 + */ + public function test_update_accepts_boolean_notations_and_persists_boolean(): void + { + $createResponse = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $this->validCouponData()); + $createResponse->assertStatus(201); + $couponId = $createResponse->json('data.id'); + + foreach ($this->acceptedNotations() as $label => [$input, $expected]) { + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$couponId}", [ + 'is_combinable' => $input, + 'per_user_limit' => 0, + ]); + + $response->assertStatus(200); + $this->assertSame( + $expected, + Coupon::find($couponId)->is_combinable, + "수정 {$label} 입력이 boolean {$this->boolLabel($expected)} 로 저장되어야 합니다." + ); + } + } + + /** + * 생성: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리 + */ + public function test_store_rejects_unparsable_value_with_422(): void + { + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $this->validCouponData([ + 'is_combinable' => 'abc', + ])); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['is_combinable']); + } + + /** + * 수정: 해석 불가값('abc')은 정규화하지 않고 boolean 규칙이 422 처리 + */ + public function test_update_rejects_unparsable_value_with_422(): void + { + $createResponse = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $this->validCouponData()); + $createResponse->assertStatus(201); + $couponId = $createResponse->json('data.id'); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$couponId}", [ + 'is_combinable' => 'abc', + 'per_user_limit' => 0, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['is_combinable']); + + // 실패한 요청이 저장값을 건드리지 않아야 한다 + $this->assertTrue(Coupon::find($couponId)->is_combinable); + } + + /** + * 생성: is_combinable 미전송이면 정규화가 개입하지 않고 그대로 통과 (has 가드) + */ + public function test_store_without_is_combinable_passes(): void + { + $data = $this->validCouponData(); + unset($data['is_combinable']); + + $response = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $data); + + $response->assertStatus(201); + } + + /** + * 수정: is_combinable 미전송이면 기존 저장값이 유지된다 (has 가드) + */ + public function test_update_without_is_combinable_preserves_stored_value(): void + { + $createResponse = $this->actingAs($this->adminUser) + ->postJson('/api/modules/sirsoft-ecommerce/admin/promotion-coupons', $this->validCouponData([ + 'is_combinable' => false, + ])); + $createResponse->assertStatus(201); + $couponId = $createResponse->json('data.id'); + + $response = $this->actingAs($this->adminUser) + ->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$couponId}", [ + 'per_user_limit' => 0, + ]); + + $response->assertStatus(200); + $this->assertFalse(Coupon::find($couponId)->is_combinable); + } + + /** + * 단언 메시지용 boolean 표기 + */ + private function boolLabel(bool $value): string + { + return $value ? 'true' : 'false'; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts index 1b31be82..63cae0cc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/fixtures/ecommerce-auth.ts @@ -43,6 +43,8 @@ type EcommerceAuthFixtures = { orderManageToken: string; /** 배송정책 조회+수정 권한 보유 토큰 (배송정책 목록 표시 검증용) */ shippingPolicyToken: string; + /** 프로모션 쿠폰 조회+등록+수정+삭제 권한 보유 토큰 (쿠폰 폼 검증용) */ + couponManageToken: string; /** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */ userToken: string; /** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */ @@ -103,6 +105,16 @@ export const test = base.extend({ ), ); }, + couponManageToken: async ({}, use) => { + await use( + issueToken( + 'sirsoft-ecommerce.promotion-coupon.read', + 'sirsoft-ecommerce.promotion-coupon.create', + 'sirsoft-ecommerce.promotion-coupon.update', + 'sirsoft-ecommerce.promotion-coupon.delete', + ), + ); + }, // userToken / customerToken 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다. // 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어 // 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다. diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/promotion-coupon-combinable.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/promotion-coupon-combinable.spec.ts new file mode 100644 index 00000000..0015bcb3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/promotion-coupon-combinable.spec.ts @@ -0,0 +1,134 @@ +/** + * 쿠폰 「타 쿠폰과 중복 사용」 라디오 boolean 저장 — 공개 이슈 #97 회귀. + * + * 결함: 라디오 클릭 시 DOM 문자열 "true"/"false" 가 폼 상태에 그대로 저장되어 + * 저장 요청이 서버 boolean 규칙에서 422 (`is combinable 필드는 true 또는 + * false여야 합니다.`) 로 실패했다. 표시 계층은 느슨 비교로 정상이라 저장 + * 시점에야 드러난다. 라디오를 클릭하지 않으면 초기 boolean 이 전송되어 + * 저장이 되는 것이 제보의 "임시 우회" 였다. + * + * 검증: 등록 화면에서 "불가능" 을 실제 클릭해 저장(POST body 의 is_combinable 이 + * boolean false)하고, 수정 화면에서 저장값 반영 + "가능" 재선택 저장(PUT 200) + * 까지 왕복한다. 생성한 쿠폰은 API 로 정리한다. + * + * @scenario issue97-coupon-combinable-boolean + * @axes request=store,update surface=browser_form + * @effects radio_click_saves_boolean_state, edit_screen_reflects_saved_value + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; + +const CREATE_URL = '/admin/ecommerce/promotion-coupon-create'; +const LIST_URL_PATTERN = /\/admin\/ecommerce\/promotion-coupons(\?|$)/; +const API_BASE = '/api/modules/sirsoft-ecommerce/admin/promotion-coupons'; + +/** 폼 자동바인딩 debounce(300ms) 유실 방지 — 필드별 순차 입력 + 대기 */ +async function fillDebounced( + page: import('@playwright/test').Page, + selector: string, + value: string, +): Promise { + await page.locator(selector).fill(value); + await page.waitForTimeout(500); +} + +test.describe('쿠폰 중복 사용 라디오 boolean 저장 (#97)', () => { + // @scenario request=store surface=browser_form + // @effects radio_click_saves_boolean_state + test('등록: "불가능" 클릭 후 저장하면 boolean false 로 201 저장되고, 수정 화면 반영 + "가능" 재저장까지 왕복한다', async ({ + page, + couponManageToken, + }) => { + await authenticatePage(page, couponManageToken); + await page.goto(CREATE_URL); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator('input[name="is_combinable"][value="false"]')).toBeVisible({ + timeout: 30_000, + }); + + // 필수값 입력 — debounce 유실 방지를 위해 필드별 순차 입력 + const today = new Date(); + const nextMonth = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + + await fillDebounced(page, 'input[name="name[ko]"]', 'E2E 중복사용 라디오 검증 쿠폰'); + await fillDebounced(page, 'input[name="discount_value"]', '1000'); + await fillDebounced(page, 'input[name="valid_from"]', fmt(today)); + await fillDebounced(page, 'input[name="valid_to"]', fmt(nextMonth)); + + // 핵심 조작: "불가능" 라디오를 실제로 클릭한다 (수정 전에는 이 클릭이 422 를 만들었다) + await page.locator('input[name="is_combinable"][value="false"]').check(); + await page.waitForTimeout(500); + + // 저장 → POST 요청 본문의 is_combinable 이 boolean false 인지 채증 + const [request, response] = await Promise.all([ + page.waitForRequest( + (req) => req.url().includes(API_BASE) && req.method() === 'POST', + { timeout: 30_000 }, + ), + page.waitForResponse( + (res) => res.url().includes(API_BASE) && res.request().method() === 'POST', + { timeout: 30_000 }, + ), + page.locator('#save_button_top').click(), + ]); + + const body = request.postDataJSON() as Record; + expect(body.is_combinable).toBe(false); // boolean false — 문자열 "false" 금지 + expect(response.status()).toBe(201); + + const created = (await response.json()) as { data?: { id?: number } }; + const couponId = created.data?.id; + expect(couponId, '생성 응답에서 쿠폰 id 를 얻지 못했습니다.').toBeTruthy(); + + try { + // 저장 성공 → 목록 복귀 + await page.waitForURL(LIST_URL_PATTERN, { timeout: 30_000 }); + + // 수정 화면: 저장된 false 가 "불가능" checked 로 반영되는지 + await page.goto(`/admin/ecommerce/promotion-coupons/${couponId}/edit`); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + const noRadio = page.locator('input[name="is_combinable"][value="false"]'); + await expect(noRadio).toBeVisible({ timeout: 30_000 }); + await expect(noRadio).toBeChecked({ timeout: 30_000 }); + + // "가능" 재선택 후 저장 → PUT 200 + body boolean true + await page.locator('input[name="is_combinable"][value="true"]').check(); + await page.waitForTimeout(500); + + const [putRequest, putResponse] = await Promise.all([ + page.waitForRequest( + (req) => req.url().includes(`${API_BASE}/${couponId}`) && req.method() === 'PUT', + { timeout: 30_000 }, + ), + page.waitForResponse( + (res) => + res.url().includes(`${API_BASE}/${couponId}`) && res.request().method() === 'PUT', + { timeout: 30_000 }, + ), + page.locator('#save_button_top').click(), + ]); + + const putBody = putRequest.postDataJSON() as Record; + expect(putBody.is_combinable).toBe(true); + expect(putResponse.status()).toBe(200); + + await page.waitForURL(LIST_URL_PATTERN, { timeout: 30_000 }); + } finally { + // 정리: 생성한 쿠폰 삭제 (페이지 컨텍스트의 토큰 재사용) + const deleteStatus = await page.evaluate( + async ({ apiBase, id }) => { + const res = await fetch(`${apiBase}/${id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${localStorage.getItem('auth_token')}`, + Accept: 'application/json', + }, + }); + return res.status; + }, + { apiBase: API_BASE, id: couponId }, + ); + expect([200, 204]).toContain(deleteStatus); + } + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue97-coupon-combinable-boolean.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue97-coupon-combinable-boolean.yaml new file mode 100644 index 00000000..7ce9b5bf --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/issue97-coupon-combinable-boolean.yaml @@ -0,0 +1,47 @@ +# audit:allow test-scenario-coverage reason: | +# cross product 자동 전개는 audit 실행 환경에 js-yaml 이 없어 fallback YAML 파서가 +# nested axes 배열을 읽지 못하는 한계로 검출되지 않는다(동일 한계로 다른 번들 시나리오도 +# audit:allow 처리됨). 입력 표기 axis 는 CouponCombinableBooleanTest 가 store/update +# 양면에서 전수(6종 수용 + 해석불가 + 미전송) 커버하고, 브라우저 축은 +# promotion-coupon-combinable.spec.ts 가 라디오 클릭 → 저장 → 수정 반영 왕복을 실측한다. +# effects ↔ 테스트 매핑은 본 매니페스트 effects 블록과 각 테스트가 1:1 대응한다. +feature: 쿠폰 중복사용 boolean 정규화 (공개 #97) + +description: | + 관리자 쿠폰 폼의 「타 쿠폰과 중복 사용」 라디오를 클릭하면 DOM 문자열 "true"/"false" 가 + 폼 상태에 저장되어 서버 `boolean` 규칙에서 422 로 저장이 실패했다 (공개 이슈 #97). + 표시 계층은 느슨 비교로 정상이라 저장 시점에야 드러난다. + + 정정은 양면이다 — ① 레이아웃이 autoBinding: false + change 액션 `=== 'true'` 캐스팅으로 + 쓰기 경로를 boolean 으로 고정하고, ② Store/UpdateCouponRequest 가 문자열 표기를 + boolean 으로 정규화해 캐시된 구 레이아웃도 방어한다. 해석 불가값은 건드리지 않아 + boolean 규칙이 422 로 처리한다. 같은 잠재 위험이 있던 게시판 is_secret 도 서버 + 정규화로 함께 방어한다 (sirsoft-board Store/UpdatePostRequest). + +axes: + # 서버가 수용/거부해야 하는 is_combinable 입력 표기 + is_combinable_input: [true, false, "true", "false", "1", "0", "abc", absent] + # 저장 경로 + request: [store, update] + # 검증 표면 + surface: [api, browser_form] + +effects: + - string_boolean_accepted_and_persisted # 문자열 표기가 boolean 으로 정규화되어 DB 에 boolean 저장 + - unparsable_value_rejected_422 # 해석 불가값은 정규화하지 않고 boolean 규칙이 422 처리 + - radio_click_saves_boolean_state # 라디오 클릭 시 폼 상태가 boolean (문자열 아님) + - edit_screen_reflects_saved_value # 수정 화면 진입 시 저장된 boolean 이 라디오 선택 상태로 반영 + +test_files: + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponCombinableBooleanTest.php + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponCombinableWritePath.test.tsx + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/couponFormLayouts.test.tsx + - modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/promotion-coupon-combinable.spec.ts + +validation: + note: | + 입력 표기 × store/update 조합은 PHPUnit 이 전수 커버한다 (browser_form 표면은 + 라디오 UI 가 만들 수 있는 값이 "true"/"false" 두 가지뿐이라 E2E 는 클릭 왕복만 + 실측한다). 쓰기 경로의 boolean 타입은 레이아웃 렌더링 테스트가 실제 partial 노드로 + 고정하고, 재발 차단은 audit 룰 `layout-boolean-option-autobinding` (error) 가 담당한다. + 게시판 is_secret 방어는 sirsoft-board 의 PostSecretBooleanNormalizationTest 가 커버한다. diff --git a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts index 4ed97ada..b7ad1c4a 100644 --- a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts +++ b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts @@ -923,6 +923,56 @@ describe('트러블슈팅 회귀 테스트 - Form 자동 바인딩 bindingType expect(merged.form.method_samsungpay).toBeNull(); }); }); + + /** + * [사례 7] 자동바인딩 value 쓰기 경로는 e.target.value 를 무변환 저장한다 + * + * troubleshooting-components-form.md 사례 8 회귀 가드 (공개 #97). + * + * bindingType 미지정(RadioGroup/Select)은 value 바인딩으로 떨어지는데, + * 그 쓰기 경로(autoOnChange)는 `eventOrValue?.target?.value !== undefined ? + * eventOrValue.target.value : eventOrValue` 로 DOM 문자열을 그대로 저장한다. + * DOM 라디오의 value 는 항상 문자열이므로 boolean 필드가 이 경로로 묶이면 + * 클릭 순간 "true"/"false" 문자열이 되어 서버 boolean 규칙에서 422 가 된다. + * 표시 계층은 String === String 느슨 비교가 구제하므로 저장 시점에야 드러난다. + */ + describe('[사례 7] 자동바인딩 value 쓰기 경로의 boolean 문자열화 (공개 #97)', () => { + /** DynamicRenderer 의 value 바인딩 autoOnChange 값 추출과 동일 */ + const calcAutoOnChangeNewValue = (eventOrValue: any): any => + eventOrValue?.target?.value !== undefined ? eventOrValue.target.value : eventOrValue; + + it('라디오 change 이벤트의 e.target.value 는 문자열 그대로 저장된다 (무변환)', () => { + // DOM 라디오는 value 를 항상 문자열로 보고한다 + const domEvent = { target: { value: 'false', name: 'is_combinable', type: 'radio' } }; + + const stored = calcAutoOnChangeNewValue(domEvent); + + expect(stored).toBe('false'); + expect(typeof stored).not.toBe('boolean'); // 이것이 서버 422 의 시작점 + }); + + it('boolean 미지정 컴포넌트(RadioGroup)는 boolean 값도 value 바인딩이라 쓰기 경로가 이 함정을 탄다', () => { + // 표시(사례 3)는 value 바인딩으로 구제되지만, 쓰기는 문자열화된다 + expect(calcIsCheckedBinding(undefined, true)).toBe(false); // value 바인딩 확정 + }); + + it('해결: change 액션의 === "true" 캐스팅은 항상 boolean 을 기록한다', () => { + // 레이아웃: "form.is_combinable": "{{$event.target.value === 'true'}}" + const castYes = 'true' === 'true'; + const castNo = ('false' as string) === 'true'; + + expect(castYes).toBe(true); + expect(castNo).toBe(false); + expect(typeof castYes).toBe('boolean'); + expect(typeof castNo).toBe('boolean'); + }); + + it('해결: 표시 바인딩 String(boolean) 은 옵션 문자열 값과 정확히 매칭된다', () => { + // 레이아웃: value: "{{String(_local.form?.is_combinable ?? true)}}" + expect(String(true)).toBe('true'); + expect(String(false)).toBe('false'); + }); + }); }); describe('트러블슈팅 회귀 테스트 - 렌더링 구조', () => {