fix(ecommerce,board): 쿠폰 중복사용 라디오 boolean 문자열화로 저장이 422 실패하던 결함 수정
공개 제보(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 + 회귀 테스트
This commit is contained in:
@@ -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 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.3",
|
||||
"version": "1.0.4",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Feature\User;
|
||||
|
||||
// 테스트 베이스 클래스 수동 require (autoload 전에 로드 필요)
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
|
||||
/**
|
||||
* 게시글 is_secret boolean 정규화 — 공개 이슈 #97 잠재 위험 방어
|
||||
*
|
||||
* 비밀글 여부를 문자열 "true"/"false" 로 보내는 클라이언트(레이아웃 hidden 시드 등)가
|
||||
* 나타나도 `boolean` 규칙에서 422 로 깨지지 않도록 Store/UpdatePostRequest 의
|
||||
* prepareForValidation 이 문자열 표기를 boolean 으로 정규화함을 고정한다.
|
||||
* 해석 불가값은 건드리지 않아 boolean 규칙이 422 로 처리한다.
|
||||
*/
|
||||
class PostSecretBooleanNormalizationTest extends BoardTestCase
|
||||
{
|
||||
private User $memberUser;
|
||||
|
||||
/**
|
||||
* 테스트 게시판 slug
|
||||
*/
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'secret-boolean-test';
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 게시판 속성 (비밀글 허용)
|
||||
*/
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $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<string, array{mixed, bool}> [입력값, 기대 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'));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 관리자 쿠폰 등록·수정에서 「타 쿠폰과 중복 사용」을 선택하면 `is combinable 필드는 true 또는 false여야 합니다.` 오류로 저장이 실패하던 문제를 수정했습니다. (#97 @lyg-kaban 님께서 제보해주셨습니다.)
|
||||
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
|
||||
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
|
||||
- 관리자 엑셀 다운로드 관리의 다운로드 이력 모달에서, 이력 항목이 아직 선택되지 않은 순간에 파일 정보·상태 배지가 비정상적으로 처리되던 문제를 보완했습니다. 항목을 선택한 뒤의 표시는 종전과 동일합니다.
|
||||
|
||||
@@ -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` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| coupon_id | integer | `52` | 삭제된 쿠폰(정의)의 ID |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "쿠폰이 삭제되었습니다.",
|
||||
"data": {
|
||||
"coupon_id": 52
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -557,11 +571,36 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
_단건 응답: `data` 객체의 필드 (`CouponResource`) — 생성(`POST /admin/promotion-coupons`) 201 응답의 `data` 와 동일 스키마이므로 필드 표는 그 절을 참조합니다._
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```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`) +
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```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
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
|
||||
+217
@@ -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 }) => (
|
||||
<div data-testid="form-field">
|
||||
{label && <span>{label}</span>}
|
||||
{children}
|
||||
{error && <span role="alert">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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<HTMLInputElement>) => void;
|
||||
inline?: boolean;
|
||||
}> = ({ name, value: valueProp, options, onChange }) => {
|
||||
const [value, setValue] = useState(() => valueProp ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
setValue(valueProp ?? '');
|
||||
}, [valueProp]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
if (onChange) onChange(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid={`radio-group-${name}`}>
|
||||
{options.map((option) => (
|
||||
<label key={String(option.value)}>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={String(option.value) === String(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 폼 상태 타입 프로브 — 엄격 === 비교로 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<typeof createLayoutTest>;
|
||||
|
||||
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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
+21
@@ -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();
|
||||
|
||||
+13
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueCondition;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueMethod;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Coupon;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 쿠폰 is_combinable boolean 정규화 — 공개 이슈 #97 회귀
|
||||
*
|
||||
* 관리자 쿠폰 폼의 라디오가 문자열 "true"/"false" 를 전송하면 `boolean` 규칙에서
|
||||
* 422 로 저장이 실패했다. Store/UpdateCouponRequest 의 prepareForValidation 이
|
||||
* 문자열 표기를 boolean 으로 정규화("false" → false 가 핵심 — truthy 오변환 방지)
|
||||
* 하고, 해석 불가값은 건드리지 않아 boolean 규칙이 422 로 처리함을 고정한다.
|
||||
*/
|
||||
class CouponCombinableBooleanTest extends ModuleTestCase
|
||||
{
|
||||
protected User $adminUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
app()->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<string, array{mixed, bool}> [입력값, 기대 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';
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ type EcommerceAuthFixtures = {
|
||||
orderManageToken: string;
|
||||
/** 배송정책 조회+수정 권한 보유 토큰 (배송정책 목록 표시 검증용) */
|
||||
shippingPolicyToken: string;
|
||||
/** 프로모션 쿠폰 조회+등록+수정+삭제 권한 보유 토큰 (쿠폰 폼 검증용) */
|
||||
couponManageToken: string;
|
||||
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
|
||||
userToken: string;
|
||||
/** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */
|
||||
@@ -103,6 +105,16 @@ export const test = base.extend<EcommerceAuthFixtures>({
|
||||
),
|
||||
);
|
||||
},
|
||||
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 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다.
|
||||
// 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어
|
||||
// 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다.
|
||||
|
||||
+134
@@ -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<void> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
+47
@@ -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 가 커버한다.
|
||||
@@ -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('트러블슈팅 회귀 테스트 - 렌더링 구조', () => {
|
||||
|
||||
Reference in New Issue
Block a user