fix(ecommerce): 숫자 설정 문자열 영속으로 인한 주문 생성 실패 해소

관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의
문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가
발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면
다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고,
저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다.

같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다.
- 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시
 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단
- 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제
- 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해
 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제

엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가
리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시
무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다.

Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의
주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면
폴백을 서버와 같은 규칙으로 맞췄다.
This commit is contained in:
HeuJung
2026-08-02 18:35:28 +09:00
parent ef2a77e0a0
commit 6bde24bc75
66 changed files with 4052 additions and 196 deletions
+5
View File
@@ -75,6 +75,10 @@
### Fixed
#### 설정 화면 입력
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
#### 확장 업데이트
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
@@ -124,6 +128,7 @@
#### 설정 저장·입력 검증
- 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다.
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
+33 -16
View File
@@ -38,6 +38,7 @@ class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}
{--no-admin-role : admin 역할을 부여하지 않고 지정한 권한만 가진 계정을 만든다 (권한 분기 검증용)}
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
@@ -76,7 +77,9 @@ class PlaywrightIssueToken extends Command
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
// --no-admin-role: 지정한 권한만 가진 계정을 만든다.
// 기본값(플래그 없음)은 종전대로 admin 역할을 함께 부여한다 — 기존 spec 무영향.
$user = $this->makeAdminUser($permissions, ! $this->option('no-admin-role'));
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
@@ -139,9 +142,18 @@ class PlaywrightIssueToken extends Command
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
* 4. (withAdminRole 일 때만) admin role 보장 (firstOrCreate) + 유저-역할 부여
*
* `withAdminRole = false` 는 **권한 분기(읽기 전용 등) 검증 전용**이다. 기본값 true 는
* admin 역할을 함께 붙이므로, 요청한 권한만 가진 세션을 만들 수 없다 —
* admin 역할이 사이트의 전체 권한을 보유하기 때문에 `--permissions` 로 좁혀도
* 화면은 항상 최대 권한으로 렌더된다(실측: admin 역할 권한 263건).
*
* @param array<int, string> $permissions 부여할 권한 식별자 목록
* @param bool $withAdminRole admin 역할 동반 부여 여부
* @return User 생성된 유저
*/
private function makeAdminUser(array $permissions): User
private function makeAdminUser(array $permissions, bool $withAdminRole = true): User
{
$user = User::factory()->create();
@@ -171,23 +183,28 @@ class PlaywrightIssueToken extends Command
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
if ($withAdminRole) {
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
}
// test role 은 항상 부여한다 — GC(pruneStaleTestArtifacts)가 이 역할로 테스트 계정을
// 식별하므로, 빠지면 --no-admin-role 로 만든 계정이 영구 잔존한다.
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
+4 -2
View File
@@ -549,7 +549,8 @@ class AuthService
}
// 4. 만료 시간 체크
$expireMinutes = config('auth.passwords.users.expire', 60);
// Carbon 날짜 연산은 strict 타입 경계라 설정이 문자열이면 TypeError 가 난다 → 정수 보장
$expireMinutes = (int) config('auth.passwords.users.expire', 60);
if ($record->created_at->addMinutes($expireMinutes)->isPast()) {
$record->delete();
@@ -598,7 +599,8 @@ class AuthService
}
// 만료 시간 체크 (기본 60분)
$expireMinutes = config('auth.passwords.users.expire', 60);
// Carbon 날짜 연산은 strict 타입 경계라 설정이 문자열이면 TypeError 가 난다 → 정수 보장
$expireMinutes = (int) config('auth.passwords.users.expire', 60);
if ($record->created_at->addMinutes($expireMinutes)->isPast()) {
// 만료된 토큰 삭제
+73 -2
View File
@@ -62,15 +62,42 @@ trait NormalizesSettingsData
$settings[$key] = $this->convertStringToMultilingual($value, $defaultValue);
}
// 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (HTML number 입력 등)
$numeric = $this->normalizeNumericScalar($value, $defaultValue);
if ($numeric !== null) {
$settings[$key] = $numeric;
}
// 배열 내부의 객체도 정규화 (currencies 같은 경우)
if (is_array($value) && is_array($defaultValue) && $this->isIndexedArray($value)) {
$settings[$key] = $this->normalizeArrayItems($value, $defaultValue);
if (is_array($value) && is_array($defaultValue)) {
$settings[$key] = $this->isIndexedArray($value)
? $this->normalizeArrayItems($value, $defaultValue)
: $this->normalizeNestedGroup($value, $defaultValue);
}
}
return $settings;
}
/**
* 중첩된 연관 배열(설정 그룹)을 같은 규칙으로 재귀 정규화합니다.
*
* 인덱스 배열은 "같은 구조의 항목 목록" 이라 normalizeArrayItems 가 담당하지만,
* 연관 배열은 "하위 설정 그룹" 이므로 카테고리와 동일하게 다루어야 합니다.
* 이 분기가 없으면 그룹 한 단계 아래의 숫자 문자열이 정규화되지 않고 남습니다.
*
* 다국어 필드(`{"ko": "...", "en": "..."}`)도 연관 배열이지만, 기본값의 각 로케일 값이
* 문자열이라 숫자 캐스트 조건에 걸리지 않아 그대로 통과합니다.
*
* @param array $group 중첩 설정 그룹
* @param array $defaults 대응 기본값 그룹
* @return array 정규화된 그룹
*/
protected function normalizeNestedGroup(array $group, array $defaults): array
{
return $this->normalizeCategoryData($group, $defaults);
}
/**
* 배열 내부 항목들을 정규화합니다.
*
@@ -125,11 +152,55 @@ trait NormalizesSettingsData
if (is_array($defaultValue) && is_string($value)) {
$item[$key] = $this->convertStringToMultilingual($value, $defaultValue);
}
// 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (환율/소수 자릿수 등)
$numeric = $this->normalizeNumericScalar($value, $defaultValue);
if ($numeric !== null) {
$item[$key] = $numeric;
}
// 항목 내부의 중첩 그룹도 동일 규칙으로 정규화
if (is_array($value) && is_array($defaultValue) && ! $this->isIndexedArray($value)) {
$item[$key] = $this->normalizeNestedGroup($value, $defaultValue);
}
}
return $item;
}
/**
* 숫자 기본값을 가진 설정의 숫자 문자열을 스칼라 숫자로 정규화합니다.
*
* HTML number 입력의 DOM 값은 문자열이고 검증 규칙(integer/numeric)은 숫자 문자열을
* 통과시키되 캐스트하지 않으므로, 설정 파일에 문자열로 영속될 수 있습니다.
* 그 값이 Carbon 처럼 strict 타입을 요구하는 경계에 닿으면 TypeError 가 발생하므로
* 조회 시점에 defaults 스키마의 타입으로 되돌립니다.
*
* 기본값이 int/float 스칼라이고 값이 숫자 문자열일 때만 변환하며,
* null·불리언·배열·비숫자 문자열은 변경하지 않습니다.
*
* @param mixed $value 현재 설정값
* @param mixed $defaultValue defaults 스키마의 기본값
* @return int|float|null 정규화된 값 (대상이 아니면 null)
*/
protected function normalizeNumericScalar(mixed $value, mixed $defaultValue): int|float|null
{
if (! is_string($value) || ! is_numeric(trim($value))) {
return null;
}
// is_int/is_float 는 불리언을 포함하지 않으므로 bool 기본값은 자동 제외된다.
if (is_int($defaultValue)) {
return (int) trim($value);
}
if (is_float($defaultValue)) {
return (float) trim($value);
}
return null;
}
/**
* 문자열을 다국어 배열로 변환합니다.
*
+23 -1
View File
@@ -117,7 +117,7 @@ spec 이 전부 실패한다.
| 데이터 종류 | 책임 영역 | 위치 | 호출 |
|---|---|---|---|
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` |
| 코어 권한/역할/유저/Sanctum 토큰 | 코어 | `app/Console/Commands/PlaywrightIssueToken.php` | `php artisan playwright:issue-token --permissions=core.xxx` (권한 경계 검증은 `--no-admin-role` 추가 — §5.1) |
| 편집기 저장 spec 대상 시드 화면 | 코어 | `app/Console/Commands/PlaywrightSeedLayout.php` | `php artisan playwright:seed-layout [--remove]` (globalSetup/globalTeardown 자동 호출) |
| 모듈 권한 (`sirsoft-ecommerce.*`) | 모듈 | 코어 커맨드의 `--permissions=` 임의 식별자 | 동일 (Permission::firstOrCreate 자동 생성) |
| 모듈 도메인 데이터 (상품/주문) | 모듈 | `modules/_bundled/{id}/src/Console/Commands/PlaywrightSeed{id}.php` | `php artisan playwright:seed-{id}` |
@@ -223,6 +223,28 @@ export const test = base.extend<AuthFixtures>({
});
```
#### 권한 경계를 검증할 때는 `issueScopedToken`
`issueToken` 은 커맨드 기본 동작대로 사이트의 `admin` 역할을 함께 부여한다. `admin` 역할은 전체
권한을 보유하므로, `--permissions` 로 권한을 좁혀 넘겨도 **화면은 항상 최대 권한으로 렌더된다**.
읽기 전용 분기·권한 미보유 분기처럼 권한 경계 자체를 검증하려면 `issueScopedToken` 을 쓴다 —
`--no-admin-role` 을 붙여 지정한 권한만 가진 계정을 만든다.
```typescript
// ❌ 읽기 전용 분기를 만들 수 없다 — admin 역할이 update 권한까지 함께 부여된다
await authenticatePage(page, issueToken('sirsoft-ecommerce.settings.read'));
await expect(page.locator('input[name="..."]')).toBeDisabled(); // 실패: enabled
// ✅ 지정한 권한만 가진 계정
await authenticatePage(page, issueScopedToken('sirsoft-ecommerce.settings.read'));
await expect(page.locator('input[name="..."]')).toBeDisabled(); // 통과
```
권한을 좁힌 계정은 코어 메뉴·알림 데이터소스에도 접근하지 못해 콘솔에 403 이 남을 수 있다.
그 화면 자체의 검증과 무관한 잡음이므로, 콘솔 에러 0 을 단언하는 테스트에는 이 토큰을 쓰지 않는다.
권한 밖 탭·라우트로 이동하면 403 에러 페이지로 전환되므로, 왕복 시나리오는 그 권한으로 접근
가능한 대상만 경유해야 한다.
### 5.2 확장 fixture — 권한 + 시드 분리
```typescript
@@ -4,6 +4,17 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.8] - 2026-08-01
### Added
- 무통장입금 자동취소 기한을 비운 채 저장할 때 나오는 안내 문구 일본어 번역 추가.
- 유효기간이 설정되지 않은 쿠폰을 발급할 때 나오는 안내 문구 일본어 번역 추가.
### Fixed
- 자동취소 기한 안내가 실제 입력 하한(1일)과 다른 "0일 이상"으로 표시되던 일본어 문구를 수정했습니다.
## [1.0.7] - 2026-07-31
### Added
@@ -311,6 +311,7 @@ return [
'not_downloadable' => 'ダウンロードできないクーポンです。',
'quantity_exhausted' => 'クーポン数量が尽きました。',
'issue_period_expired' => 'クーポン発行期間が終了しました。',
'validity_not_configured' => 'クーポンの有効期間が設定されていないため発行できません。管理者にお問い合わせください。',
'expired' => '有効期限切れのクーポンです。',
'min_amount_not_met' => '最小注文金額の条件を満たしていません。',
'not_combinable' => '他のクーポンと併用できないクーポンです。',
@@ -1577,8 +1577,9 @@ return [
'boolean' => '未決済の自動キャンセルの可否は真偽値である必要があります。',
],
'auto_cancel_days' => [
'required' => '自動キャンセルの期限を入力してください。',
'integer' => '自動キャンセルの期限は整数である必要があります。',
'min' => '自動キャンセルの期限は0日以上である必要があります。',
'min' => '自動キャンセルの期限は1日以上である必要があります。',
'max' => '自動キャンセルの期限は最大30日まで設定可能です。',
],
'cart_expiry_days' => [
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.7",
"version": "1.0.8",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -28,6 +28,7 @@
### Fixed
- 게시판 설정의 '새 글 표시 시간'이 저장 직후에는 숫자가 아닌 형태로 다뤄져, 조회 시점에 따라 값의 형태가 달라지던 문제를 수정했습니다. 새 글 표시 여부 판정에 쓰이는 값이므로 항상 숫자로 처리합니다.
- 글을 쓰면서 파일을 함께 첨부하면 글은 저장되는데 첨부파일만 사라지던 문제를 수정했습니다. 첨부 개수·용량·형식 검사와 권한 확인은 모두 통과한 뒤 저장 단계에서만 빠져, 등록된 글에 첨부가 하나도 남지 않았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시판 신고 정책의 자동 숨김 기준 횟수를 0(자동 숨김 사용 안 함)으로 입력할 수 없던 문제를 수정했습니다. 서버는 0을 "사용 안 함"으로 처리하는데 화면에서만 1 이상을 요구해, 0을 입력하면 저장은 되면서도 입력칸이 계속 오류 상태로 남았습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -180,6 +180,8 @@ class Board extends Model
'use_comment' => 'boolean',
'use_reply' => 'boolean',
'max_reply_depth' => 'integer',
// Post::isNew() 가 Carbon 시간 연산에 넘기는 값 — 조회 시점과 무관하게 정수를 보장한다.
'new_display_hours' => 'integer',
'use_report' => 'boolean',
'use_file_upload' => 'boolean',
'notify_author' => 'boolean',
@@ -329,8 +331,9 @@ class Board extends Model
*
* g7_permissions 및 role_permissions 테이블에서 권한 정보를 조회하여
* 각 권한별로 할당된 역할 identifier 배열을 반환합니다.
* (접근자 결과: 키 permission_key, 값 [role_identifiers] 또는 null=전체 허용)
*
* @return array 권한 정보 (키: permission_key, 값: [role_identifiers] or null)
* @return Attribute 권한 정보 접근자
*/
protected function permissions(): Attribute
{
@@ -0,0 +1,82 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Models;
use Illuminate\Support\Facades\DB;
use Modules\Sirsoft\Board\Models\Board;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* new_display_hours 정수 캐스트 회귀 테스트
*
* Post::isNew() 는 게시판의 new_display_hours 를 Carbon subHours() 에 넘긴다.
* subHours 는 값을 음수화하며 넘기므로 숫자 문자열은 우연히 통과하지만(비숫자 문자열은
* "Unsupported operand types" 로 실패), 같은 값이 addHours 계열에 닿는 순간 TypeError 가 된다.
*
* 더 직접적인 결함은 타입 불일치다 — DB 왕복 후에는 드라이버가 INT 컬럼을 정수로 반환하지만,
* 요청 입력으로 모델에 대입된 직후(재조회 전)에는 문자열이 그대로 남아 API 응답/비교 연산의
* 타입이 조회 시점에 따라 달라진다. 형제 숫자 컬럼(max_reply_depth/posts_count 등)과
* 동일하게 integer 캐스트로 통일한다.
*
* @effects board_new_display_hours_is_integer
*/
class NewDisplayHoursCastTest extends BoardTestCase
{
#[Test]
public function new_display_hours_is_integer_right_after_mass_assignment(): void
{
// 요청 입력(HTML number → 문자열)으로 모델을 만든 직후, 재조회 없이 읽는 경로
$board = new Board(['new_display_hours' => '48']);
$this->assertSame(48, $board->new_display_hours);
}
#[Test]
public function new_display_hours_is_integer_after_create_without_refresh(): void
{
$board = Board::create([
'slug' => 'cast-new-display-hours',
'name' => ['ko' => '캐스트 테스트', 'en' => 'Cast Test'],
'is_active' => true,
'new_display_hours' => '48',
]);
$this->assertSame(48, $board->new_display_hours);
}
#[Test]
public function new_display_hours_is_cast_to_integer_when_loaded_from_db(): void
{
DB::table('boards')->where('id', $this->board->id)->update(['new_display_hours' => 12]);
$board = Board::findOrFail($this->board->id);
$this->assertSame(12, $board->new_display_hours);
}
#[Test]
public function post_is_new_does_not_crash_when_board_attribute_is_string(): void
{
$postId = $this->createTestPost();
$post = Post::with('board')->findOrFail($postId);
// 문자열 유입 재현 (캐스트 도입 후 정수로 해석되어야 함 — 비회귀 가드)
$post->board->new_display_hours = '24';
$this->assertTrue($post->isNew());
}
#[Test]
public function post_is_new_returns_false_for_old_post(): void
{
$postId = $this->createTestPost();
DB::table('board_posts')->where('id', $postId)->update(['created_at' => now()->subHours(5)]);
$post = Post::with('board')->findOrFail($postId);
$post->board->new_display_hours = '1';
$this->assertFalse($post->isNew());
}
}
@@ -34,6 +34,12 @@
### Fixed
- 미입금 자동취소 기한을 비운 채 저장하면 "저장되었습니다"라고 안내되면서도 값은 사라지던 문제를 수정했습니다. 숫자 항목에 숫자가 아닌 값을 입력하면 입력칸이 비워지는데, 그대로 저장하면 설정이 없는 상태로 남아 화면에 보이는 값과 실제 동작이 어긋났습니다. 이제 비워 둔 채로는 저장되지 않고 값을 입력하라고 안내합니다.
- 미입금 자동취소 기한 입력 안내가 실제 입력 하한과 다른 "0일 이상"으로 표시되던 문구를 "1일 이상"으로 바로잡았습니다.
- 쿠폰 수정 화면에서 유효기간을 "발급일로부터"로 두고 일수를 비운 채 저장할 수 있던 문제를 수정했습니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어, 받은 회원이 사용할 수 없으면서도 아무 오류가 나지 않았습니다. 이제 저장 단계에서 일수를 입력하도록 안내하고, 이미 그렇게 저장돼 있던 쿠폰은 발급 시 유효기간이 설정되지 않았다고 알려 줍니다.
- 리뷰 작성 기한 판정이 화면 표시와 실제 저장 가능 여부에서 서로 다른 기준을 쓸 수 있던 문제를 수정했습니다. 이제 두 곳이 같은 설정값을 사용하므로 "작성 가능"으로 보이는데 저장이 거부되는 일이 없습니다.
- 관리자 환경설정에서 미입금 자동취소 기한을 한 번이라도 저장하면, 이후 모든 무통장입금·가상계좌 주문이 결제 처리 오류로 실패하던 문제를 수정했습니다. 저장된 기한 값의 형태 때문에 입금기한을 계산하지 못한 것으로, 이제 저장 형태와 무관하게 주문이 정상 생성됩니다. 기한이 비어 있거나 허용 범위(1~30일)를 벗어난 값이면 기본값 3일로 처리합니다. (#85 @hwaryeon1234 님께서 제보해주셨습니다.)
- 설정 화면에서 숫자 항목(기한·수량·금액 등)을 저장할 때 값이 숫자가 아닌 형태로 보관되던 문제를 수정했습니다. 화면 표시는 같았지만 저장된 형태가 달라, 그 값을 사용하는 기능에서 오류가 날 수 있었습니다.
- 설정 화면의 선택 항목(라디오 버튼)을 키보드 방향키로 고를 때 선택이 저장되지 않던 문제를 수정했습니다. 마우스 클릭은 정상 동작했으나, 키보드만 사용하는 경우 화면 표시와 실제 저장 값이 어긋날 수 있었습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 로그인하지 않은 상태로 장바구니에 담아 둔 상품이 로그인 후 사라지던 문제를 수정했습니다. 로그인 시 장바구니를 회원 계정으로 옮기는 처리가 실행되지 않아, 담아 둔 상품이 아무 안내 없이 없어졌습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 설정 화면이 허용하는 값과 저장 규칙이 서로 달라 저장이 거부되거나 그 반대가 되던 항목들을 맞췄습니다. 리뷰 이미지 최대 개수(최대 20개), 리뷰 이미지 최대 크기(정수 MB, 최대 50MB), 마일리지 기본 적립률(최대 100%), 미입금 자동취소 기한(최소 1일 — 0일은 주문 즉시 만료라 사용할 수 없는 값)이 대상입니다. 정수만 받는 항목은 입력칸의 증감 단위도 1로 맞춰, 화살표로 값을 올리다 소수가 되어 저장이 거부되는 일이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -2050,6 +2050,11 @@ HTTP/1.1 200
**설명** 관리자가 이커머스 환경설정을 저장합니다. `permission:sirsoft-ecommerce.settings.update` 권한이 필요하며, `_tab` 으로 저장할 카테고리를 지정하고 각 섹션(`basic_info`·`shipping`·`claim` 등)을 배열로 전달합니다. `EcommerceSettingsService::saveSettings()`가 JSON 설정을 저장하되, DB 관리 대상인 `shipping.carriers`·`shipping.types`·`claim.refund_reasons` 는 분리해 각 Service 의 sync 메서드로 동기화합니다. 저장 성공 시 `sirsoft-ecommerce.settings.after_save` 훅을 발화하고, 관리자 UI 상태 갱신을 위해 병합된 전체 설정을 다시 반환합니다.
**숫자 필드 정규화** 검증 규칙에 `integer` / `numeric` 이 선언된 모든 필드는 검증 직전에 숫자 타입으로 캐스트되어 저장됩니다(중첩 배열의 와일드카드 경로 포함 — 예: `mileage.currency_rules.*.use_unit`). HTML `number` 입력의 값은 문자열(`"5"`)로 전송되고 Laravel 의 `integer` 규칙은 숫자 문자열을 통과시키되 캐스트하지 않으므로, 정규화가 없으면 문자열이 그대로 설정 파일에 영속되어 이후 날짜/수치 연산에서 타입 오류를 유발합니다.
- 정규화 대상: 정수 표기 문자열(`"5"`, `"05"`) → `int`, 소수 표기 문자열(`"1.5"`) → `float`(단, `numeric` 필드에 한함)
- 정규화 제외: 비숫자 문자열(`"abc"`), 빈 문자열, `null`, 불리언, 그리고 `integer` 필드에 전달된 소수 문자열(`"3.7"`) — 검증을 느슨하게 만들지 않기 위해 캐스트하지 않고 그대로 검증 실패시킵니다
- 조회(`GET`) 응답도 `defaults.json` 스키마의 숫자 타입으로 정규화되어 반환되므로, 저장 시점의 표현 형태와 무관하게 숫자로 수신됩니다
### PUT /api/modules/sirsoft-ecommerce/admin/settings/banks
<!-- @generated:start:api.modules.sirsoft-ecommerce.admin.settings.store-banks -->
@@ -0,0 +1,41 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Exceptions;
use RuntimeException;
/**
* 쿠폰 발급 불가 예외
*
* 발급 관문(assertIssuable / assertWithinUserLimit)이 거절한 상태에서 발급을 시도할 때
* 발생합니다. 사유 식별자는 `messages.coupon.*` 의 키와 같은 이름을 쓰며, 그 키로 사용자
* 안내 문구를 만듭니다.
*
* 직접 발급 배치는 회원별로 이 예외를 잡아 skipped 목록에 사유를 담고, 컨트롤러는
* `catch (\Exception)` 으로 400 응답을 만듭니다. RuntimeException 상속이라 두 흐름 모두
* 종전과 동일하게 동작합니다.
*/
class CouponNotIssuableException extends RuntimeException
{
/**
* @param string $reason 발급 불가 사유 식별자 (messages.coupon.* 키와 동일)
*/
public function __construct(
private string $reason
) {
parent::__construct(
__("sirsoft-ecommerce::messages.coupon.{$reason}"),
400
);
}
/**
* 발급 불가 사유 식별자 반환
*
* @return string 사유 식별자
*/
public function getReason(): string
{
return $this->reason;
}
}
@@ -0,0 +1,84 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns;
use Illuminate\Validation\Validator;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CouponRepositoryInterface;
/**
* 쿠폰 유효기간 쌍(valid_type / valid_days) 정합성 검증 trait
*
* `valid_type = days_from_issue` 인데 `valid_days` 가 비면 만료일을 계산할 수 없어
* 발급 시각과 만료 시각이 같아집니다. 그렇게 저장된 쿠폰은 발급되는 즉시 만료 상태가 되어
* 받은 회원이 쓸 수 없는데, 예외도 로그도 남지 않아 조용히 흘러갑니다.
*
* 판정 기준은 "이번 요청에 무엇이 왔는가" 가 아니라 **저장 후 확정될 조합** 입니다.
* 생성은 두 필드가 항상 요청에 있지만, 수정은 부분 갱신이라 한쪽만 올 수 있고 나머지는
* 저장값을 승계해야 하기 때문입니다. 그래서 규칙 배열의 `required_if` 대신 이 trait 로
* 정책을 단일화합니다 — `required_if` 는 조건 필드(`valid_type`)가 요청에 없으면 아예
* 발화하지 않아, `valid_days: null` 만 보내는 요청이 그대로 통과합니다(실측).
*
* 생성 요청에서는 승계할 저장값이 없으므로 이 trait 가 `required_if` 와 정확히 같은 강도로
* 동작하고, 수정 요청에서는 그 상위집합이 됩니다.
*/
trait ValidatesCouponValidityPair
{
/**
* 유효기간 유형과 일수의 조합 정합성 검증을 등록합니다.
*
* 유효기간 쌍을 건드리지 않는 요청(이름만 수정 등)은 판정 대상에서 제외합니다.
* 그러지 않으면 이미 깨진 조합으로 저장된 쿠폰을 고칠 길이 사라집니다.
*
* @param Validator $validator 검증기 인스턴스
*/
protected function validateValidityPair(Validator $validator): void
{
if (! $this->has('valid_type') && ! $this->has('valid_days')) {
return;
}
$validator->after(function (Validator $validator) {
$coupon = $this->existingCouponForValidity();
$effectiveType = $this->has('valid_type')
? $this->input('valid_type')
: $coupon?->valid_type;
if ($effectiveType !== 'days_from_issue') {
return;
}
$effectiveDays = $this->has('valid_days')
? $this->input('valid_days')
: $coupon?->valid_days;
if (is_numeric($effectiveDays) && (int) $effectiveDays > 0) {
return;
}
$validator->errors()->add(
'valid_days',
__('sirsoft-ecommerce::validation.coupon.valid_days_required')
);
});
}
/**
* 승계 대상이 되는 기존 쿠폰을 조회합니다.
*
* 생성 요청에는 라우트 키가 없으므로 항상 null 이며, 그때는 요청값만으로 판정합니다.
*
* @return Coupon|null 라우트가 가리키는 쿠폰 (생성 요청·미존재 시 null)
*/
protected function existingCouponForValidity(): ?Coupon
{
$id = $this->route('id');
if ($id === null) {
return null;
}
return app(CouponRepositoryInterface::class)->findById((int) $id);
}
}
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponTargetScope;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponValidityPair;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -25,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Models\Product;
class StoreCouponRequest extends FormRequest
{
use ValidatesCouponTargetScope;
use ValidatesCouponValidityPair;
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
@@ -44,6 +46,7 @@ class StoreCouponRequest extends FormRequest
public function withValidator(Validator $validator): void
{
$this->validateTargetScopeSelection($validator);
$this->validateValidityPair($validator);
}
/**
@@ -102,7 +105,10 @@ class StoreCouponRequest extends FormRequest
// 유효기간
'valid_type' => 'required|string|in:period,days_from_issue',
'valid_days' => 'nullable|required_if:valid_type,days_from_issue|integer|min:1',
// days_from_issue 쌍의 정합성은 ValidatesCouponValidityPair 가 Update 와 공통으로 판정한다.
// 규칙 배열의 required_if 는 조건 필드가 요청에 없으면 발화하지 않아 부분 수정 경로에서
// 우회로가 되므로, 두 요청이 같은 trait 를 쓰도록 정책을 한 곳으로 모았다.
'valid_days' => 'nullable|integer|min:1',
'valid_from' => 'nullable|required_if:valid_type,period|date',
'valid_to' => 'nullable|required_if:valid_type,period|date|after_or_equal:valid_from',
@@ -27,6 +27,138 @@ class StoreEcommerceSettingsRequest extends FormRequest
return true;
}
/**
* 검증 전 입력 데이터 정규화
*
* HTML number 입력의 DOM 값은 문자열이고, Laravel 의 integer/numeric 규칙은 숫자 문자열을
* 통과시키되 캐스트하지 않는다. 캐스트하지 않으면 문자열이 그대로 설정 파일에 영속되어,
* 이후 Carbon 같은 strict 타입 경계에서 TypeError 가 발생한다(무통장입금 주문 500 실패).
*
* 대상 필드는 rules() 에서 파생한다 — 손으로 열거하면 규칙이 추가될 때 누락된다.
*/
protected function prepareForValidation(): void
{
$data = $this->all();
$changed = false;
foreach ($this->rules() as $field => $fieldRules) {
$castType = $this->numericCastTypeFor($fieldRules);
if ($castType === null) {
continue;
}
$changed = $this->castNumericPath($data, explode('.', $field), $castType) || $changed;
}
if ($changed) {
$this->replace($data);
}
}
/**
* 검증 규칙 목록에서 숫자 캐스트 종류를 판정합니다.
*
* @param mixed $fieldRules 단일 필드의 검증 규칙
* @return string|null 'integer' | 'numeric' | null(캐스트 대상 아님)
*/
private function numericCastTypeFor(mixed $fieldRules): ?string
{
$list = is_array($fieldRules) ? $fieldRules : explode('|', (string) $fieldRules);
$castType = null;
foreach ($list as $rule) {
if (! is_string($rule)) {
continue;
}
if ($rule === 'integer') {
return 'integer';
}
if ($rule === 'numeric') {
$castType = 'numeric';
}
}
return $castType;
}
/**
* 도트 경로(와일드카드 `*` 포함)를 따라가며 숫자 문자열을 캐스트합니다.
*
* @param array $data 대상 데이터 (참조로 변경)
* @param array<int, string> $segments 경로 세그먼트
* @param string $castType 'integer' | 'numeric'
* @return bool 값이 하나라도 변경되었으면 true
*/
private function castNumericPath(array &$data, array $segments, string $castType): bool
{
$segment = array_shift($segments);
if ($segment === '*') {
$changed = false;
foreach ($data as $key => $unused) {
if (is_array($data[$key])) {
$changed = $this->castNumericPath($data[$key], $segments, $castType) || $changed;
}
}
return $changed;
}
if (! array_key_exists($segment, $data)) {
return false;
}
if ($segments !== []) {
if (! is_array($data[$segment])) {
return false;
}
return $this->castNumericPath($data[$segment], $segments, $castType);
}
$cast = $this->castNumericValue($data[$segment], $castType);
if ($cast === null) {
return false;
}
$data[$segment] = $cast;
return true;
}
/**
* 단일 값을 숫자로 캐스트합니다.
*
* 검증을 느슨하게 만들지 않기 위해, integer 필드는 정수 표기 문자열만 캐스트한다
* (예: "3.7" 은 캐스트하지 않아 integer 규칙에서 그대로 실패해야 한다).
*
* @param mixed $value 원본 값
* @param string $castType 'integer' | 'numeric'
* @return int|float|null 캐스트 결과 (대상이 아니면 null)
*/
private function castNumericValue(mixed $value, string $castType): int|float|null
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
if ($castType === 'integer') {
return preg_match('/^[+-]?\d+$/', $trimmed) === 1 ? (int) $trimmed : null;
}
if (! is_numeric($trimmed)) {
return null;
}
// numeric 필드는 정수 표기를 int 로 유지해 저장 round-trip 타입 오염을 막는다.
return preg_match('/^[+-]?\d+$/', $trimmed) === 1 ? (int) $trimmed : (float) $trimmed;
}
/**
* 요청에 적용할 검증 규칙
*
@@ -133,7 +265,12 @@ class StoreEcommerceSettingsRequest extends FormRequest
'order_settings.bank_accounts.*.is_default' => ['nullable', 'boolean'],
'order_settings.auto_cancel_expired' => ['nullable', 'boolean'],
// 0 은 now()->addDays(0) = 즉시 만료라 실질적으로 사용할 수 없는 값이다 → UI(min:1)가 옳다
'order_settings.auto_cancel_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)],
// nullable 금지: 숫자칸에 비숫자를 넣으면 브라우저가 값을 "" 로 비우고,
// ConvertEmptyStringsToNull 로 null 이 되어 "저장되었습니다" 를 띄운 채 값만 사라진다.
// 이후 소비처의 숨은 기본값(3일)으로 동작해 화면 표시와 실제 동작이 어긋난다.
// sometimes 필수: rules() 는 탭 구분 없이 적용되므로 무조건 required 로 두면
// 이 키를 보내지 않는 다른 탭(마일리지 등) 저장이 통째로 막힌다. 키가 온 경우에만 필수.
'order_settings.auto_cancel_days' => ['sometimes', 'required', 'integer', 'min:'.config('sirsoft-ecommerce.limits.auto_cancel_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.auto_cancel_days_max', 30)],
'order_settings.cart_expiry_days' => ['nullable', 'integer', 'min:'.config('sirsoft-ecommerce.limits.cart_expiry_days_min', 1), 'max:'.config('sirsoft-ecommerce.limits.cart_expiry_days_max', 365)],
'order_settings.stock_restore_on_cancel' => ['nullable', 'boolean'],
'order_settings.confirmable_statuses' => ['nullable', 'array'],
@@ -1030,6 +1167,7 @@ class StoreEcommerceSettingsRequest extends FormRequest
'order_settings.bank_accounts.*.is_active.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.bank_accounts.is_active.boolean'),
'order_settings.bank_accounts.*.is_default.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.bank_accounts.is_default.boolean'),
'order_settings.auto_cancel_expired.boolean' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_expired.boolean'),
'order_settings.auto_cancel_days.required' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.required'),
'order_settings.auto_cancel_days.integer' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.integer'),
'order_settings.auto_cancel_days.min' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.min'),
'order_settings.auto_cancel_days.max' => __('sirsoft-ecommerce::validation.custom.order_settings.auto_cancel_days.max'),
@@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Enums\CouponIssueStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponTargetScope;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\Concerns\ValidatesCouponValidityPair;
use Modules\Sirsoft\Ecommerce\Models\Category;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -25,6 +26,7 @@ use Modules\Sirsoft\Ecommerce\Models\Product;
class UpdateCouponRequest extends FormRequest
{
use ValidatesCouponTargetScope;
use ValidatesCouponValidityPair;
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
@@ -44,6 +46,7 @@ class UpdateCouponRequest extends FormRequest
public function withValidator(Validator $validator): void
{
$this->validateTargetScopeSelection($validator);
$this->validateValidityPair($validator);
}
/**
@@ -102,11 +105,12 @@ class UpdateCouponRequest extends FormRequest
// 다른 탭만 고치는 요청도 1인당 사용 제한을 매번 실어 보내야 저장된다.
'per_user_limit' => 'sometimes|required|integer|min:0',
// 유효기간 — 조건부 규칙은 StoreCouponRequest 와 동일해야 한다.
// required_if 는 조건 필드(valid_type)가 요청에 없으면 발화하지 않으므로
// 부분 수정을 깨지 않으면서 "기간 지정인데 기간이 비어 있는" 저장만 차단한다.
// days_from_issue 쌍의 정합성은 ValidatesCouponValidityPair 가 Store 와 공통으로 판정한다.
// 규칙 배열의 required_if 는 조건 필드(valid_type)가 요청에 없으면 발화하지 않아,
// `valid_days: null` 만 보내는 부분 수정이 그대로 통과한다(실측). 저장된 유형이
// days_from_issue 인 쿠폰이 그 경로로 일수를 잃으면 발급 즉시 만료되는 쿠폰이 조용히 나간다.
'valid_type' => 'sometimes|required|string|in:period,days_from_issue',
'valid_days' => 'nullable|required_if:valid_type,days_from_issue|integer|min:1',
'valid_days' => 'nullable|integer|min:1',
'valid_from' => 'nullable|required_if:valid_type,period|date',
'valid_to' => 'nullable|required_if:valid_type,period|date|after_or_equal:valid_from',
@@ -8,6 +8,7 @@ use Illuminate\Http\Request;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
use Modules\Sirsoft\Ecommerce\Models\ShippingType;
use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy;
/**
* 주문 옵션 리소스
@@ -287,17 +288,11 @@ class OrderOptionResource extends BaseApiResource
/**
* 구매확정 시점 기준 리뷰 작성 기한이 지났는지 판정
*
* ProductReviewService::canWrite 와 동일한 경계 비교를 사용한다
* (confirmed_at + N일 < now). N(write_deadline_days)이 0 이하면 무제한으로 간주.
* 판정 규칙은 ReviewWritePolicy 단일 SSoT 를 따른다 — 화면 표시(이 리소스)와
* 실제 저장 가능 여부(ProductReviewService::canWrite)가 어긋나지 않도록 한다.
*/
private function isReviewDeadlinePassed(): bool
{
$deadlineDays = (int) module_setting('sirsoft-ecommerce', 'review_settings.write_deadline_days', 90);
if (! $this->confirmed_at || $deadlineDays <= 0) {
return false;
}
return now()->gt($this->confirmed_at->copy()->addDays($deadlineDays));
return ReviewWritePolicy::isDeadlinePassed($this->confirmed_at);
}
}
@@ -2,6 +2,8 @@
namespace Modules\Sirsoft\Ecommerce\Http\Resources\Traits;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
/**
* 다중 통화 가격 변환 Trait
*
@@ -10,11 +12,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Resources\Traits;
*/
trait HasMultiCurrencyPrices
{
/**
* 통화 설정 캐시 (동일 요청 내 중복 조회 방지)
*/
private static ?array $currencySettingsCache = null;
/**
* 부모 주문 리소스가 주입한 주문 시점 기준 통화 코드 (자식 리소스용).
*
@@ -96,12 +93,7 @@ trait HasMultiCurrencyPrices
*/
protected function getCurrencySettings(): array
{
if (self::$currencySettingsCache === null) {
$settings = g7_module_settings('sirsoft-ecommerce', 'language_currency');
self::$currencySettingsCache = $settings['currencies'] ?? [];
}
return self::$currencySettingsCache;
return CurrencySettingsCache::currencies();
}
/**
@@ -369,9 +361,11 @@ trait HasMultiCurrencyPrices
* 통화 설정 캐시를 초기화합니다.
*
* 테스트 또는 설정 변경 시 캐시를 리셋해야 할 때 사용합니다.
* 실제 보관소는 CurrencySettingsCache 단일 클래스이므로, 어느 사용 클래스에서
* 호출하든 전체가 비워집니다.
*/
public static function clearCurrencySettingsCache(): void
{
self::$currencySettingsCache = null;
CurrencySettingsCache::clear();
}
}
@@ -365,6 +365,24 @@ class Coupon extends Model implements FulltextSearchable
return $this->issued_count.'/'.$this->total_quantity;
}
/**
* 유효기간 설정이 만료일을 계산할 수 있는 상태인지 확인
*
* valid_days 는 nullable 컬럼이라 (valid_type=days_from_issue, valid_days=NULL) 조합이
* 저장될 수 있습니다. 그대로 발급하면 만료일이 발급 시각과 같아져 사실상 즉시 만료된
* 쿠폰이 조용히 나갑니다. 발급 전에 이 조합을 걸러내기 위한 판정입니다.
*
* @return bool 만료일 계산이 가능한 설정이면 true
*/
public function hasResolvableValidity(): bool
{
if ($this->valid_type !== 'days_from_issue') {
return true;
}
return is_numeric($this->valid_days) && (int) $this->valid_days > 0;
}
/**
* 발급 가능 여부 확인
*
@@ -382,6 +400,11 @@ class Coupon extends Model implements FulltextSearchable
return false;
}
// 유효기간 미설정 (발급일 기준인데 일수가 비어 있음)
if (! $this->hasResolvableValidity()) {
return false;
}
// 발급 기간 확인
$now = now();
if ($this->issue_from && $now->lt($this->issue_from)) {
@@ -481,6 +504,8 @@ class Coupon extends Model implements FulltextSearchable
/**
* MySQL FULLTEXT 엔진에서는 인덱스 업데이트가 불필요합니다.
*
* @return bool 검색 인덱스를 갱신해야 하면 true
*/
public function searchIndexShouldBeUpdated(): bool
{
@@ -46,6 +46,15 @@ use Modules\Sirsoft\Ecommerce\Support\VatCalculator;
*/
class OrderProcessingService
{
/** 입금기한 기본값(일) — 설정이 비어 있거나 사용할 수 없는 값일 때 적용 */
private const AUTO_CANCEL_DAYS_DEFAULT = 3;
/** 입금기한 허용 하한(일) — 설정(limits)에 값이 없을 때의 fallback */
private const AUTO_CANCEL_DAYS_MIN_FALLBACK = 1;
/** 입금기한 허용 상한(일) — 설정(limits)에 값이 없을 때의 fallback */
private const AUTO_CANCEL_DAYS_MAX_FALLBACK = 30;
public function __construct(
protected OrderRepositoryInterface $orderRepository,
protected TempOrderService $tempOrderService,
@@ -928,9 +937,7 @@ class OrderProcessingService
if ($paymentMethod === PaymentMethodEnum::VBANK->value) {
$paymentData['vbank_holder'] = $depositorName;
// 입금기한 단일 SSoT: auto_cancel_days (결제수단 무관)
$paymentData['vbank_due_at'] = Carbon::now()->addDays(
module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3)
);
$paymentData['vbank_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
}
// 무통장입금 (수동 입금) 정보
@@ -954,14 +961,48 @@ class OrderProcessingService
// 입금기한 단일 SSoT: auto_cancel_days (VBANK 와 동일 기준).
// 클라이언트가 보낸 due_days 는 무시한다 — 기한은 서버 정책이며, 이를 받아들이면
// 미입금 자동취소 스케줄러와 안내 기한이 어긋난다.
$paymentData['deposit_due_at'] = Carbon::now()->addDays(
module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', 3)
);
$paymentData['deposit_due_at'] = Carbon::now()->addDays($this->resolveAutoCancelDays());
}
$order->payment()->create($paymentData);
}
/**
* 입금기한(일)을 정수로 해석합니다.
*
* 설정값은 관리자 화면(HTML number 입력)을 거치면서 문자열로 저장될 수 있고,
* Carbon 의 날짜 연산은 strict 타입 경계라 문자열을 받으면 TypeError 를 던진다.
* 저장 타입과 무관하게 정수를 보장하고, 허용 범위를 벗어난 값은 안전한 값으로 보정한다.
*
* @return int 입금기한 일수 (허용 범위로 클램프된 값)
*/
private function resolveAutoCancelDays(): int
{
$fallback = self::AUTO_CANCEL_DAYS_DEFAULT;
$raw = module_setting('sirsoft-ecommerce', 'order_settings.auto_cancel_days', $fallback);
$days = is_numeric($raw) ? (int) $raw : $fallback;
// 0 이하(빈 문자열/미설정/오염값)는 즉시 만료를 뜻하므로 기본값으로 되돌린다.
if ($days <= 0) {
$days = $fallback;
}
// 허용 범위는 검증 규칙(StoreEcommerceSettingsRequest)과 같은 limits 설정을 SSoT 로 쓴다.
// 여기서 다시 좁히는 것이 아니라, 검증을 거치지 않고 영속된 값(구버전 데이터·직접 편집)만
// 안전 범위로 되돌린다 — 주문 생성 자체를 실패시키지 않기 위함.
$min = max(
self::AUTO_CANCEL_DAYS_MIN_FALLBACK,
(int) config('sirsoft-ecommerce.limits.auto_cancel_days_min', self::AUTO_CANCEL_DAYS_MIN_FALLBACK)
);
$max = max(
$min,
(int) config('sirsoft-ecommerce.limits.auto_cancel_days_max', self::AUTO_CANCEL_DAYS_MAX_FALLBACK)
);
return max($min, min($max, $days));
}
/**
* 결제명(상품명 요약)을 생성합니다.
*
@@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductReview;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewImageRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy;
/**
* 상품 리뷰 서비스
@@ -96,17 +97,9 @@ class ProductReviewService
return ['can_write' => false, 'reason' => 'not_confirmed'];
}
// 작성 기간 확인
$deadlineDays = (int) $this->settingsService->getSetting(
'review_settings.write_deadline_days',
config('ecommerce.review.write_deadline_days', 90)
);
if ($orderOption->confirmed_at && $deadlineDays > 0) {
$deadline = $orderOption->confirmed_at->addDays($deadlineDays);
if (now()->gt($deadline)) {
return ['can_write' => false, 'reason' => 'deadline_passed'];
}
// 작성 기간 확인 (판정 규칙은 ReviewWritePolicy 단일 SSoT)
if (ReviewWritePolicy::isDeadlinePassed($orderOption->confirmed_at)) {
return ['can_write' => false, 'reason' => 'deadline_passed'];
}
// 중복 작성 확인
@@ -3,12 +3,12 @@
namespace Modules\Sirsoft\Ecommerce\Services;
use App\Extension\HookManager;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Exceptions\CouponNotIssuableException;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Product;
@@ -309,7 +309,7 @@ class UserCouponService
* @param int $couponId 쿠폰 ID
* @return CouponIssue 생성된 발급 레코드
*
* @throws Exception 다운로드 불가 시
* @throws CouponNotIssuableException 다운로드 불가 시
*/
public function downloadCoupon(int $userId, int $couponId): CouponIssue
{
@@ -319,7 +319,7 @@ class UserCouponService
$coupon = $this->couponRepository->findByIdForUpdate($couponId);
if (! $coupon) {
throw new Exception(__('sirsoft-ecommerce::messages.coupon.not_downloadable'), 400);
throw new CouponNotIssuableException('not_downloadable');
}
// 발급 가능 조건 + per_user_limit 검증 (위반 시 사유별 예외)
@@ -340,7 +340,7 @@ class UserCouponService
*
* @param Coupon $coupon 발급 대상 쿠폰
*
* @throws Exception 발급 불가 시(상태/재고/기간)
* @throws CouponNotIssuableException 발급 불가 시(상태/재고/유효기간 미설정/기간)
*/
public function assertIssuable(Coupon $coupon): void
{
@@ -349,12 +349,15 @@ class UserCouponService
}
if ($coupon->issue_status->value !== 'issuing') {
throw new Exception(__('sirsoft-ecommerce::messages.coupon.not_downloadable'), 400);
throw new CouponNotIssuableException('not_downloadable');
}
if ($coupon->total_quantity !== null && $coupon->issued_count >= $coupon->total_quantity) {
throw new Exception(__('sirsoft-ecommerce::messages.coupon.quantity_exhausted'), 400);
throw new CouponNotIssuableException('quantity_exhausted');
}
throw new Exception(__('sirsoft-ecommerce::messages.coupon.issue_period_expired'), 400);
if (! $coupon->hasResolvableValidity()) {
throw new CouponNotIssuableException('validity_not_configured');
}
throw new CouponNotIssuableException('issue_period_expired');
}
/**
@@ -363,13 +366,13 @@ class UserCouponService
* @param Coupon $coupon 발급 대상 쿠폰
* @param int $userId 발급 대상 회원 ID
*
* @throws Exception 한도 초과 시
* @throws CouponNotIssuableException 한도 초과 시
*/
public function assertWithinUserLimit(Coupon $coupon, int $userId): void
{
$userIssuedCount = $this->couponIssueRepository->getUserIssuedCountForCoupon($userId, $coupon->id);
if ($coupon->per_user_limit > 0 && $userIssuedCount >= $coupon->per_user_limit) {
throw new Exception(__('sirsoft-ecommerce::messages.coupon.download_limit_exceeded'), 400);
throw new CouponNotIssuableException('download_limit_exceeded');
}
}
@@ -382,7 +385,7 @@ class UserCouponService
* @param int $userId 발급 대상 회원 ID
* @return CouponIssue 생성된 발급 레코드
*
* @throws Exception per_user_limit 초과 시
* @throws CouponNotIssuableException per_user_limit 초과 시
*/
public function issueDirectlyToUser(Coupon $coupon, int $userId): CouponIssue
{
@@ -408,11 +411,18 @@ class UserCouponService
$couponCode = $codePrefix.'-'.strtoupper(Str::random(8));
// expired_at 계산
//
// valid_days 는 nullable 컬럼이라 캐스트를 거쳐도 NULL/문자열이 그대로 도착할 수 있다.
// Carbon 은 strict 타입 경계라 숫자 문자열을 받으면 TypeError 를 던지고, NULL 은
// 0 일로 흡수해 "발급 즉시 만료" 라는 조용한 오작동을 만든다. 정수로 확정한 뒤
// 계산하고, 계산 불가(0 이하)면 기간지정 쿠폰의 valid_to 가 비어 있을 때와 같은
// 규약(만료 없음)을 따른다. 이 조합은 assertIssuable 이 앞단에서 차단한다.
$expiredAt = null;
if ($coupon->valid_type === 'period') {
$expiredAt = $coupon->valid_to;
} elseif ($coupon->valid_type === 'days_from_issue') {
$expiredAt = now()->addDays($coupon->valid_days);
$validDays = is_numeric($coupon->valid_days) ? (int) $coupon->valid_days : 0;
$expiredAt = $validDays > 0 ? now()->addDays($validDays) : null;
}
// CouponIssue 생성
@@ -0,0 +1,50 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Support;
/**
* 통화 설정 요청 단위 캐시 (단일 보관소)
*
* 통화 설정은 한 요청 안에서 여러 리소스가 반복해서 읽는다. 이전에는 캐시를
* `HasMultiCurrencyPrices` 트레이트의 `private static` 으로 두었는데, **트레이트의 static 은
* 사용 클래스마다 별개의 사본**이라 실제로는 캐시가 20벌 존재했다. 그래서
*
* - 요청당 설정 파일 읽기가 클래스 수만큼 반복되고,
* - 초기화 메서드를 호출해도 그 클래스의 사본만 비워져 나머지는 그대로 남았다.
*
* 후자는 테스트에서 드러났다 — 앞선 테스트가 어떤 리소스 클래스의 사본을 채워 두면
* 뒤 테스트가 그 값을 그대로 물려받아, 단독으로는 통과하는 테스트가 함께 돌리면
* 실패했다(기본 통화 소수 자릿수가 달라져 금액 타입이 int ↔ float 로 갈렸다).
*
* 캐시를 이 클래스 하나로 모아 "비우면 전부 비워지는" 상태로 만든다.
*/
class CurrencySettingsCache
{
/**
* 캐시된 통화 목록 (미조회 시 null).
*/
private static ?array $currencies = null;
/**
* 통화 설정 목록을 반환합니다. (최초 1회만 설정을 읽고 이후 캐시)
*
* @return array 통화 설정 배열
*/
public static function currencies(): array
{
if (self::$currencies === null) {
$settings = g7_module_settings('sirsoft-ecommerce', 'language_currency');
self::$currencies = $settings['currencies'] ?? [];
}
return self::$currencies;
}
/**
* 캐시를 비웁니다. (설정 변경 후 / 테스트 격리)
*/
public static function clear(): void
{
self::$currencies = null;
}
}
@@ -0,0 +1,60 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Support;
use Carbon\CarbonInterface;
/**
* 리뷰 작성 기한 정책 헬퍼
*
* "구매확정 후 N일까지 리뷰 작성 가능" 판정을 한 곳으로 모은다.
*
* 기존에는 `ProductReviewService::canWrite` 와 `OrderOptionResource::isReviewDeadlinePassed`
* 가 같은 규칙을 각자 구현했다. 조회 방식(주입 서비스 vs `module_setting` 헬퍼)과 기본값
* 출처(config vs 리터럴)가 서로 달라, 한쪽만 바뀌면 화면 표시와 실제 저장 가능 여부가
* 어긋난다. 게다가 서비스 쪽 config 폴백은 네임스페이스가 잘못돼(`ecommerce.*`) 항상
* null 로 해석되고 있었다 — 모듈 config 는 `sirsoft-ecommerce.*` 로 등록된다.
*/
class ReviewWritePolicy
{
/**
* 기한 미설정 시 사용할 기본 일수.
*/
public const DEFAULT_DEADLINE_DAYS = 90;
/**
* 리뷰 작성 가능 기간(일)을 반환합니다.
*
* 0 이하는 "무제한" 을 뜻하므로 그대로 돌려주고 판정 측에서 해석한다.
*
* @return int 작성 가능 일수 (0 이하면 무제한)
*/
public static function deadlineDays(): int
{
$fallback = (int) config(
'sirsoft-ecommerce.review.write_deadline_days',
self::DEFAULT_DEADLINE_DAYS
);
$days = module_setting('sirsoft-ecommerce', 'review_settings.write_deadline_days', $fallback);
return is_numeric($days) ? (int) $days : $fallback;
}
/**
* 구매확정 시점 기준으로 작성 기한이 지났는지 판정합니다.
*
* @param CarbonInterface|null $confirmedAt 구매확정 일시 (미확정이면 null)
* @return bool 기한이 지났으면 true (미확정·무제한이면 false)
*/
public static function isDeadlinePassed(?CarbonInterface $confirmedAt): bool
{
$days = self::deadlineDays();
if (! $confirmedAt || $days <= 0) {
return false;
}
return now()->gt($confirmedAt->copy()->addDays($days));
}
}
@@ -341,6 +341,7 @@ return [
'not_downloadable' => 'This coupon is not available for download.',
'quantity_exhausted' => 'Coupon quantity exhausted.',
'issue_period_expired' => 'Coupon issue period has ended.',
'validity_not_configured' => 'This coupon cannot be issued because its validity period is not configured. Please contact the administrator.',
// Coupon validation errors (DTO/ValidationError)
'expired' => 'This coupon has expired.',
'min_amount_not_met' => 'Minimum order amount not met.',
@@ -1743,8 +1743,9 @@ return [
'boolean' => 'Auto cancel unpaid orders option must be true or false.',
],
'auto_cancel_days' => [
'required' => 'Please enter the auto cancel days.',
'integer' => 'Auto cancel days must be an integer.',
'min' => 'Auto cancel days must be at least 0.',
'min' => 'Auto cancel days must be at least 1.',
'max' => 'Auto cancel days cannot exceed 30.',
],
'cart_expiry_days' => [
@@ -345,6 +345,7 @@ return [
'not_downloadable' => '다운로드할 수 없는 쿠폰입니다.',
'quantity_exhausted' => '쿠폰 수량이 소진되었습니다.',
'issue_period_expired' => '쿠폰 발급 기간이 종료되었습니다.',
'validity_not_configured' => '쿠폰의 유효기간이 설정되어 있지 않아 발급할 수 없습니다. 관리자에게 문의해주세요.',
// 쿠폰 검증 오류 (DTO/ValidationError)
'expired' => '만료된 쿠폰입니다.',
'min_amount_not_met' => '최소 주문 금액 조건을 충족하지 않습니다.',
@@ -1743,8 +1743,9 @@ return [
'boolean' => '미결제 자동취소 여부는 참/거짓 값이어야 합니다.',
],
'auto_cancel_days' => [
'required' => '자동취소 기한을 입력해주세요.',
'integer' => '자동취소 기한은 정수여야 합니다.',
'min' => '자동취소 기한은 0일 이상이어야 합니다.',
'min' => '자동취소 기한은 1일 이상이어야 합니다.',
'max' => '자동취소 기한은 최대 30일까지 설정 가능합니다.',
],
'cart_expiry_days' => [
@@ -0,0 +1,266 @@
<?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\Services\UserCouponService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 쿠폰 유효기간 정합성 — valid_type=days_from_issue 인데 valid_days 가 비어 있는 상태
*
* 생성 요청(StoreCouponRequest)에는 required_if 가 있으나 수정 요청에는 없어,
* 수정 경로로 (days_from_issue, valid_days=null) 조합을 저장할 수 있었습니다.
*
* Carbon 은 NULL 을 0 일로 흡수하므로 이 조합은 예외를 던지지 않습니다(실측: `addDays(null)`
* 은 무변화, TypeError 는 숫자 문자열에서만 발생). 대신 만료일이 발급 시각과 같아져
* **발급 즉시 만료된 쿠폰이 조용히 발급**됩니다 — 예외도 로그도 없는 오작동입니다.
*
* 그래서 ① 수정 요청에서 확정될 조합을 검증해 저장을 막고, ② 이미 저장된 데이터는
* 발급 관문(assertIssuable)에서 명시적으로 차단합니다.
*
* @effects coupon_update_rejects_days_from_issue_without_valid_days, coupon_issue_blocked_when_validity_not_configured
*/
class CouponValidDaysIntegrityTest 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 오버라이드할 속성
* @return Coupon 생성된 쿠폰
*/
private function makeCoupon(array $overrides = []): Coupon
{
return Coupon::create(array_merge([
'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT->value,
'discount_type' => CouponDiscountType::FIXED->value,
'discount_value' => 1000,
'min_order_amount' => 0,
'issue_method' => CouponIssueMethod::DIRECT->value,
'issue_condition' => CouponIssueCondition::MANUAL->value,
'issue_status' => CouponIssueStatus::ISSUING->value,
'per_user_limit' => 0,
'valid_type' => 'days_from_issue',
'valid_days' => 30,
'is_combinable' => true,
'target_scope' => CouponTargetScope::ALL->value,
], $overrides));
}
/**
* 수정 요청 본문 기본값 (부분 수정이므로 필수 필드만)
*
* @param array $overrides 오버라이드할 속성
*/
private function updatePayload(array $overrides = []): array
{
return array_merge([
'per_user_limit' => 0,
], $overrides);
}
/**
* 수정: valid_type=days_from_issue 로 바꾸면서 valid_days 를 비우면 거부된다.
*
* @effects coupon_update_rejects_days_from_issue_without_valid_days
*/
public function test_update_rejects_days_from_issue_with_null_valid_days(): void
{
$coupon = $this->makeCoupon([
'valid_type' => 'period',
'valid_days' => null,
'valid_from' => now(),
'valid_to' => now()->addMonth(),
]);
$response = $this->actingAs($this->adminUser)
->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([
'valid_type' => 'days_from_issue',
'valid_days' => null,
]));
$response->assertStatus(422);
$response->assertJsonValidationErrors(['valid_days']);
}
/**
* 수정: valid_type 은 그대로(days_from_issue) 두고 valid_days 만 비워도 거부된다.
*
* valid_type 이 요청에 없으므로, 저장돼 있던 값을 승계해 판정해야 잡힌다.
*
* @effects coupon_update_rejects_days_from_issue_without_valid_days
*/
public function test_update_rejects_clearing_valid_days_when_existing_type_is_days_from_issue(): void
{
$coupon = $this->makeCoupon(['valid_days' => 30]);
$response = $this->actingAs($this->adminUser)
->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([
'valid_days' => null,
]));
$response->assertStatus(422);
$response->assertJsonValidationErrors(['valid_days']);
$this->assertSame(30, $coupon->fresh()->valid_days);
}
/**
* 수정: valid_type 만 days_from_issue 로 보내도 저장된 valid_days 가 있으면 통과한다.
*
* @effects coupon_update_rejects_days_from_issue_without_valid_days
*/
public function test_update_allows_type_change_when_stored_valid_days_exists(): void
{
$coupon = $this->makeCoupon(['valid_days' => 15]);
$response = $this->actingAs($this->adminUser)
->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([
'valid_type' => 'days_from_issue',
]));
$response->assertStatus(200);
$this->assertSame(15, $coupon->fresh()->valid_days);
}
/**
* 수정: 유효기간과 무관한 부분 수정은 차단되지 않는다. (과잉 차단 회귀 고정)
*
* 이미 valid_days 가 비어 있는 기존 쿠폰이라도, 요청이 유효기간 쌍을 건드리지
* 않으면 통과해야 한다 — 그렇지 않으면 이름 변경조차 막힌다.
*
* @effects coupon_update_rejects_days_from_issue_without_valid_days
*/
public function test_update_unrelated_field_is_not_blocked_on_legacy_broken_coupon(): void
{
$coupon = $this->makeCoupon(['valid_days' => null]);
$response = $this->actingAs($this->adminUser)
->putJson("/api/modules/sirsoft-ecommerce/admin/promotion-coupons/{$coupon->id}", $this->updatePayload([
'name' => ['ko' => '이름만 변경', 'en' => 'Name only'],
]));
$response->assertStatus(200);
}
/**
* 발급 관문: 이미 저장돼 있던 (days_from_issue, valid_days=null) 쿠폰은 발급이 차단된다.
*
* 수정 전에는 만료일이 발급 시각과 같아져 즉시 만료된 쿠폰이 조용히 발급됐다.
*
* @effects coupon_issue_blocked_when_validity_not_configured
*/
public function test_issue_is_blocked_when_valid_days_is_null(): void
{
$coupon = $this->makeCoupon(['valid_days' => null]);
$this->assertFalse($coupon->hasResolvableValidity());
$this->assertFalse($coupon->isIssuable());
$this->expectExceptionMessage(
__('sirsoft-ecommerce::messages.coupon.validity_not_configured')
);
app(UserCouponService::class)->assertIssuable($coupon);
}
/**
* 발급 관문: 0 일도 만료일을 만들 수 없으므로 동일하게 차단된다.
*
* @effects coupon_issue_blocked_when_validity_not_configured
*/
public function test_issue_is_blocked_when_valid_days_is_zero(): void
{
$coupon = $this->makeCoupon(['valid_days' => 0]);
$this->assertFalse($coupon->isIssuable());
$this->expectExceptionMessage(
__('sirsoft-ecommerce::messages.coupon.validity_not_configured')
);
app(UserCouponService::class)->assertIssuable($coupon);
}
/**
* 발급 관문: 기간지정 쿠폰은 valid_days 가 비어 있어도 영향받지 않는다. (과잉 차단 방지)
*
* @effects coupon_issue_blocked_when_validity_not_configured
*/
public function test_period_coupon_is_unaffected_by_empty_valid_days(): void
{
$coupon = $this->makeCoupon([
'valid_type' => 'period',
'valid_days' => null,
'valid_from' => now(),
'valid_to' => now()->addMonth(),
]);
$this->assertTrue($coupon->hasResolvableValidity());
$this->assertTrue($coupon->isIssuable());
}
/**
* 발급: 정상값은 그대로 발급일 + N일이 된다. (기준선)
*
* @effects coupon_issue_blocked_when_validity_not_configured
*/
public function test_issue_uses_valid_days_when_configured(): void
{
$coupon = $this->makeCoupon(['valid_days' => 7]);
$member = User::factory()->create();
$issue = app(UserCouponService::class)->issueDirectlyToUser($coupon, $member->id);
$this->assertNotNull($issue->expired_at);
$this->assertSame(
now()->addDays(7)->format('Y-m-d'),
$issue->expired_at->format('Y-m-d')
);
}
/**
* 발급: 속성이 숫자 문자열로 도착해도 Carbon 경계에서 터지지 않는다.
*
* 이 계획이 제거한 크래시(숫자 문자열 → Carbon strict TypeError)와 같은 부류를
* 모델 속성 경유 경로에서도 고정한다.
*
* @effects coupon_issue_blocked_when_validity_not_configured
*/
public function test_issue_survives_numeric_string_valid_days(): void
{
$coupon = $this->makeCoupon(['valid_days' => 7]);
$coupon->setAttribute('valid_days', '7');
$member = User::factory()->create();
$issue = app(UserCouponService::class)->issueDirectlyToUser($coupon, $member->id);
$this->assertSame(
now()->addDays(7)->format('Y-m-d'),
$issue->expired_at->format('Y-m-d')
);
}
}
@@ -10,10 +10,13 @@ use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
use Modules\Sirsoft\Ecommerce\Enums\ProductSalesStatus;
use Modules\Sirsoft\Ecommerce\Models\ClaimReason;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
@@ -24,6 +27,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
* 비회원 토큰 후속 액션(verify, cancel, 배송지 변경 등) 의 회원 주문 보호/경계를 검증한다.
*
* @scenario actor=guest, change_mode=manual, e2e_browser=chromium
*
* @effects guest_update_shipping_address_succeeds_with_valid_token,
* guest_update_shipping_address_blocked_404_without_or_invalid_token,
* guest_update_shipping_address_validation_422_when_recipient_fields_missing
@@ -367,8 +371,8 @@ class OrderControllerTest extends ModuleTestCase
]])
);
app(\Modules\Sirsoft\Ecommerce\Services\PaymentMethodResolver::class)->flushCache();
app(\Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService::class)->clearCache();
app(PaymentMethodResolver::class)->flushCache();
app(EcommerceSettingsService::class)->clearCache();
}
/**
@@ -383,7 +387,7 @@ class OrderControllerTest extends ModuleTestCase
*
* @effects requires_pg_payment_true, pg_payment_handler_present, extension_id_passes_validation
*/
public function test_간편결제_주문_생성_응답이_PG_결제_계약을_내려준다(): void
public function test_간편결제_주문_생성_응답이_p_g_결제_계약을_내려준다(): void
{
$this->registerExtensionEasyPayMethod();
$this->createGuestTempOrder();
@@ -518,6 +522,163 @@ class OrderControllerTest extends ModuleTestCase
$this->assertNull($order->guest_lookup_password_hash);
}
/**
* 문자열로 저장된 auto_cancel_days 설정 파일을 만들고, 테스트 후 정리합니다.
*
* 관리자 화면(HTML number 입력)을 한 번 저장하면 실제로 이 형태로 영속된다.
*
* @param string $storedValue 저장 형태 그대로의 값
* @return callable 정리 콜백
*/
private function withStringAutoCancelDays(string $storedValue): callable
{
$settingsDir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsDir)) {
mkdir($settingsDir, 0755, true);
}
$path = $settingsDir.'/order_settings.json';
file_put_contents($path, json_encode(['auto_cancel_days' => $storedValue]));
return function () use ($path) {
if (file_exists($path)) {
unlink($path);
}
};
}
/**
* 10-A. 입금기한 설정이 문자열로 저장되어 있어도 비회원 무통장 주문이 생성된다
*
* 수정 전: Carbon 이 strict 타입 경계라 문자열을 받으면 TypeError → 주문 생성 500.
*
* @effects order_creation_succeeds_with_string_setting
*/
public function test_비회원_무통장_주문은_입금기한_설정이_문자열이어도_생성된다(): void
{
$cleanup = $this->withStringAutoCancelDays('5');
try {
$this->createGuestTempOrder();
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$this->guestOrderPayload(),
['X-Cart-Key' => $this->cartKey]
);
$response->assertStatus(201);
$order = Order::latest('id')->first();
$this->assertNotNull($order->payment->deposit_due_at);
$this->assertSame(
now()->addDays(5)->toDateString(),
$order->payment->deposit_due_at->toDateString(),
);
} finally {
$cleanup();
}
}
/**
* 10-B. 입금기한 설정이 문자열로 저장되어 있어도 회원 무통장 주문이 생성된다
*
* @effects order_creation_succeeds_with_string_setting
*/
public function test_회원_무통장_주문은_입금기한_설정이_문자열이어도_생성된다(): void
{
$cleanup = $this->withStringAutoCancelDays('5');
try {
$user = $this->createUser();
$this->actingAs($user);
TempOrder::create([
'user_id' => $user->id,
'cart_key' => $this->cartKey,
'items' => [[
'product_id' => $this->product->id,
'product_option_id' => $this->productOption->id,
'quantity' => 2,
]],
'calculation_result' => [
'items' => [[
'product_id' => $this->product->id,
'product_option_id' => $this->productOption->id,
'quantity' => 2,
'unit_price' => 15000,
'subtotal' => 30000,
'final_amount' => 30000,
]],
'summary' => [
'subtotal' => 30000,
'total_discount' => 0,
'total_shipping' => 0,
'payment_amount' => 30000,
'final_amount' => 30000,
],
],
'expires_at' => now()->addMinutes(30),
]);
$payload = $this->guestOrderPayload();
unset($payload['guest_lookup_password'], $payload['guest_lookup_password_confirmation']);
$this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$payload,
['X-Cart-Key' => $this->cartKey]
)->assertStatus(201);
$order = Order::where('user_id', $user->id)->latest('id')->first();
$this->assertSame(
now()->addDays(5)->toDateString(),
$order->payment->deposit_due_at->toDateString(),
);
} finally {
$cleanup();
}
}
/**
* 10-C. 입금기한 설정이 문자열이어도 가상계좌(vbank) 주문이 생성된다
*
* dbank 와 같은 산정 경로(auto_cancel_days)를 쓰지만 기록 컬럼(`vbank_due_at`)이 달라
* HTTP 레벨에서도 따로 고정한다.
*
* @effects order_creation_succeeds_with_string_setting
*/
public function test_비회원_가상계좌_주문은_입금기한_설정이_문자열이어도_생성된다(): void
{
$cleanup = $this->withStringAutoCancelDays('5');
try {
$this->createGuestTempOrder();
$payload = $this->guestOrderPayload([
'payment_method' => PaymentMethodEnum::VBANK->value,
]);
unset($payload['dbank']);
$response = $this->postJson(
'/api/modules/sirsoft-ecommerce/user/orders',
$payload,
['X-Cart-Key' => $this->cartKey]
);
$response->assertStatus(201);
$order = Order::latest('id')->first();
$this->assertNotNull($order->payment->vbank_due_at);
$this->assertSame(
now()->addDays(5)->toDateString(),
$order->payment->vbank_due_at->toDateString(),
);
} finally {
$cleanup();
}
}
/**
* 10-1. 회원 주문은 주문자 이메일이 없어도 생성된다 (이메일 필수는 비회원 한정)
*/
@@ -579,7 +740,7 @@ class OrderControllerTest extends ModuleTestCase
* 결제수단(card)을 선택했더라도 결제할 금액이 0원이면 PG 호출 없이 통과해야 한다.
* 추후 예치금 등 다른 비현금 충당이 추가되어도 동일하게 동작한다(판정 기준 = total_due_amount).
*/
public function test_전액_마일리지_결제는_PG_없이_결제완료(): void
public function test_전액_마일리지_결제는_p_g_없이_결제완료(): void
{
$this->enableMileageForFeature();
@@ -587,7 +748,7 @@ class OrderControllerTest extends ModuleTestCase
$this->actingAs($user);
// 결제액 전액(30,000) 충당용 마일리지 잔액 시드
\Modules\Sirsoft\Ecommerce\Models\MileageTransaction::create([
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
@@ -934,7 +1095,6 @@ class OrderControllerTest extends ModuleTestCase
// 회원 응답은 OrderResource — user 필드가 포함되어 비회원 응답(GuestOrderResource)과 구분됨
$this->assertArrayHasKey('user', $data);
// 회귀 차단: 회원 분기도 getDetail() 로 풀로드되어 shippings/shipping_address 가 응답에 포함되어야 한다.
// (이전 회귀: getByOrderNumber() 만 호출 시 whenLoaded 가 빈 응답 → 화면에 배송 메모/배송 현황 미표시)
$this->assertArrayHasKey('shippings', $data, '회원 응답에 shippings 누락 — getByOrderNumber 만 호출 시 whenLoaded 가 빈 응답');
@@ -27,6 +27,7 @@ use Modules\Sirsoft\Ecommerce\Models\ShippingType;
use Modules\Sirsoft\Ecommerce\Module;
use Modules\Sirsoft\Ecommerce\Providers\EcommerceServiceProvider;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
use Tests\TestCase;
/**
@@ -79,16 +80,23 @@ abstract class ModuleTestCase extends TestCase
*/
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => $this->shouldSeed(),
'--seeder' => $this->seeder(),
'--path' => [
base_path('database/migrations'),
$this->getModuleBasePath().'/database/migrations',
],
'--realpath' => true,
'--path' => $paths,
];
}
@@ -185,6 +193,11 @@ abstract class ModuleTestCase extends TestCase
if (method_exists(ShippingType::class, 'clearCodeCache')) {
ShippingType::clearCodeCache();
}
// - CurrencySettingsCache: 통화 설정을 요청 단위로 캐시한다. 비우지 않으면 앞선 테스트가
// 읽어 둔 통화 구성(기본 통화의 소수 자릿수 등)을 뒤 테스트가 물려받아, 금액 필드가
// int 로 나와야 할 자리에서 float 이 나오는 등 단독 통과 / 함께 실행 실패가 발생한다.
CurrencySettingsCache::clear();
}
/**
@@ -0,0 +1,469 @@
/**
* E2E: 무통장입금(dbank) 체크아웃 완료 — 입금기한 산정이 500 없이 끝난다
*
* 시나리오 매니페스트: `modules/_bundled/sirsoft-ecommerce/tests/scenarios/numeric-setting-type-safety.yaml`
* (케이스 마킹은 각 test 의 docblock 에 있다 — 파일 헤더에 두면 축 값이 비어 매칭되지 않는다)
*
* 배경: 관리자 주문설정을 한 번만 저장해도 `auto_cancel_days` 가 문자열(`"5"`)로 영속됐다.
* HTML `type="number"` 의 DOM 값은 문자열이고 Laravel `integer` 규칙은 숫자 문자열을 통과시키되
* 캐스트하지 않기 때문이다. 그 값이 `Carbon::now()->addDays(...)` 에 닿으면
* `rawAddUnit(int|float)` 가 `declare(strict_types=1)` 파일에서 호출되므로 TypeError 가 났고,
* **모든 무통장입금·가상계좌 주문이 500 으로 실패**했다.
*
* 이 결함은 PHPUnit 이 red 를 잡을 수 있는 형태지만, 실제로 발견된 경로는 브라우저였다.
* 상품 선택 → 체크아웃 → 결제하기까지의 흐름 전체가 이어져야 주문 생성 요청이 나가고,
* 그 응답이 500 이면 화면은 토스트 한 줄만 띄운 채 멈춘다. 그래서 흐름 전체를 브라우저로 고정한다.
*
* 검증 지점 3개:
* ① 공개 결제설정 응답의 입금기한이 문자열이 아니다 (계층 2·3 — 요청 캐스트 + 조회 정규화)
* ② 회원 — 주문서 화면에서 무통장 선택 → 결제하기 → 201 + 완료 화면 입금기한 (계층 1)
* ③ 비회원 — 같은 브라우저 세션의 주문 생성 요청으로 201 + 입금기한
*
* 커버리지 경계(침묵 누락 방지): ③ 은 주문서 **화면**을 거치지 않는다. 비회원은 저장된 배송지가
* 없어 우편번호·주소 칸을 외부 주소검색 위젯으로만 채울 수 있는데(두 칸 모두 `readOnly` — 실측),
* 외부 위젯을 E2E 로 몰면 이 스펙이 결제 회귀가 아니라 그 위젯의 가용성을 감시하게 된다.
* 비회원 주문서 화면의 브라우저 실측은 Chrome MCP 매트릭스 D2 로 1회 확보되어 있고,
* 여기서는 회귀가 실제로 터지는 지점(서버의 입금기한 산정)을 같은 세션의 요청으로 고정한다.
*
* 부작용: ②③ 은 실제 주문을 생성한다(결제 대기 상태). 무통장입금은 외부 PG 를 거치지 않으므로
* 샌드박스 의존이 없고, 생성된 주문은 설정된 기한이 지나면 미입금 자동취소 대상이 된다.
*
* @see .claude/docs/frontend/troubleshooting-backend.md 사례 21
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
const PAYMENT_SETTINGS_API = '/api/modules/sirsoft-ecommerce/settings/payment';
const ORDER_CREATE_API = /\/api\/modules\/sirsoft-ecommerce\/(user|guest)\/orders(\?|$)/;
/** 기본 입금기한 — 서버 `OrderProcessingService::AUTO_CANCEL_DAYS_DEFAULT` 와 같은 값 */
const AUTO_CANCEL_DAYS_FALLBACK = 3;
/**
* 이 스펙은 직렬로 실행한다.
*
* 세 테스트가 같은 재고를 소비하고 같은 체크아웃 세션(임시주문)을 만든다. 병렬로 돌리면
* 재고/임시주문이 서로 간섭해 결함이 아닌 실패가 난다.
*/
test.describe.configure({ mode: 'serial' });
/**
* 기본 30초로는 부족하다 — 상품 상세 → 체크아웃 → 주문 생성까지 SPA 라우팅이 세 번 일어나고
* 각 단계가 자체 데이터소스를 기다린다. 단계별 대기(20~30초)의 합보다 크게 잡아야
* 결함이 아닌 타임아웃으로 red 가 나지 않는다.
*/
test.setTimeout(180_000);
/**
* 공개 결제설정에서 입금기한을 읽는다.
*
* 이 값이 화면 안내("입금 기한: N일 이내")와 서버 산정의 공통 입력이므로, 기대 기한을
* 여기서 뽑아야 설정을 바꾼 환경에서도 스펙이 그대로 성립한다(값 하드코딩 금지).
*/
async function readOrderSettings(
page: Page
): Promise<{ raw: unknown; days: number; accounts: any[] }> {
const response = await page.request.get(PAYMENT_SETTINGS_API);
expect(response.status()).toBe(200);
const body = await response.json();
const orderSettings = body?.data?.order_settings;
// 존재를 먼저 확정한다 — 없는 객체에 대고 타입 단언을 하면 그 단언은 무의미하게 통과한다.
expect(orderSettings, '공개 결제설정 응답에 order_settings 가 없다').toBeTruthy();
const raw = orderSettings.auto_cancel_days;
const days = typeof raw === 'number' && raw > 0 ? raw : AUTO_CANCEL_DAYS_FALLBACK;
const accounts = (orderSettings.bank_accounts ?? []).filter((a: any) => a?.is_active !== false);
return { raw, days, accounts };
}
/**
* GDPR 쿠키 동의 배너를 닫는다.
*
* 배너는 화면 최상단 레이어라 열려 있으면 뒤쪽 버튼 클릭이 가로채인다. 동의 여부는 이 스펙의
* 관심사가 아니므로, 떠 있으면 닫고 없으면 넘어간다(이미 동의된 세션에서는 렌더되지 않는다).
*/
async function dismissCookieBanner(page: Page): Promise<void> {
const acceptAll = page.getByRole('button', { name: '모두 동의' });
if (await acceptAll.isVisible({ timeout: 5_000 }).catch(() => false)) {
await acceptAll.click();
await expect(acceptAll).toBeHidden({ timeout: 10_000 });
}
}
/**
* 회원에게 기본 배송지가 없으면 하나 만든다.
*
* 주문서의 우편번호·주소 칸은 `readOnly` 이고 외부 주소검색 위젯으로만 채워진다(실측).
* 외부 위젯을 E2E 로 몰 수는 없으므로, 저장된 배송지를 미리 두어 주문서가 그 값으로
* 채워진 채 열리게 한다 — 실제 회원의 재구매 동선과 같은 상태다.
*/
async function ensureDefaultAddress(page: Page, token: string): Promise<any> {
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
const endpoint = '/api/modules/sirsoft-ecommerce/user/addresses';
const readList = async (): Promise<any[]> => {
const response = await page.request.get(endpoint, { headers });
if (response.status() !== 200) return [];
const body = await response.json();
const addresses = body?.data?.addresses?.data ?? body?.data?.addresses ?? body?.data ?? [];
return Array.isArray(addresses) ? addresses : [];
};
let addresses = await readList();
if (addresses.length === 0) {
const created = await page.request.post(endpoint, {
headers,
data: {
name: 'E2E 기본 배송지',
recipient_name: 'E2E수령인',
recipient_phone: '010-1234-5678',
country_code: 'KR',
zipcode: '06236',
address: '서울특별시 강남구 테헤란로 1',
address_detail: 'E2E 101호',
is_default: true,
},
});
expect(created.status(), '기본 배송지 생성이 실패했다').toBe(201);
// 생성 응답의 봉투 형태에 기대지 않는다 — 목록을 다시 읽어 화면과 같은 표현을 쓴다.
addresses = await readList();
}
expect(addresses.length, '기본 배송지를 확보하지 못했다').toBeGreaterThan(0);
return addresses.find((a: any) => a.is_default) ?? addresses[0];
}
/**
* 주문서의 저장된 배송지 칩을 눌러 주소를 채운다.
*
* 저장된 배송지가 있어도 주문서는 자동 적용하지 않고 선택을 기다린다 — 누르지 않으면
* 우편번호·주소가 빈 채로 남아 '결제하기' 가 계속 비활성이다(실측). 칩 라벨은 배송지 이름이라
* 환경마다 다르므로 API 로 받은 이름으로 지목한다.
*/
async function applySavedAddress(page: Page, address: any): Promise<void> {
const label = String(address?.name ?? '').trim();
expect(label, '저장된 배송지에 이름이 없다').not.toBe('');
const chip = page.getByRole('button').filter({ hasText: label }).first();
await expect(chip, `저장된 배송지 "${label}" 칩이 없다`).toBeVisible({ timeout: 20_000 });
await chip.click();
// 주소가 실제로 실렸는지 확정한다 — 비어 있으면 뒤의 '결제하기' 비활성이 회귀로 오독된다.
await expect
.poll(async () => (await page.locator('input[name="zipcode"]').first().inputValue()).trim(), {
timeout: 20_000,
})
.not.toBe('');
}
/**
* 판매중인 상품 하나로 '바로구매' 임시주문을 만들고 체크아웃 화면을 연다.
*
* 상품 상세의 옵션 선택은 이 스펙이 지키려는 대상이 아닌데도 가장 불안정하다 —
* `Select` 는 옵션 데이터가 실릴 때까지 네이티브 `<select>`(빈 목록)로 렌더되다가
* 커스텀 드롭다운(버튼 + 포털 리스트박스)으로 바뀌고, 레이아웃의 `name` 은 자동바인딩이
* 소비해 DOM 에 남지 않는다(실측). 그 위에 스펙을 세우면 결제 회귀가 아니라 옵션 위젯의
* 렌더 타이밍을 감시하게 된다.
*
* 그래서 **결제 직전 상태까지는 실제 API 로 만들고**, 회귀가 일어난 구간(체크아웃 화면 →
* 결제하기 → 주문 생성)만 브라우저로 몬다. 임시주문은 화면의 '바로구매' 가 호출하는 것과
* 같은 엔드포인트·같은 payload(`direct_items`) 다.
*/
async function createDirectCheckout(
page: Page,
token?: string,
cartKey?: string
): Promise<number> {
const headers: Record<string, string> = { Accept: 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
// 비회원의 장바구니·임시주문·주문 생성은 모두 이 헤더로 묶인다. 빠지면 주문 단계에서
// 임시주문을 찾지 못해 404 가 난다(실측).
if (cartKey) headers['X-Cart-Key'] = cartKey;
const listResponse = await page.request.get('/api/modules/sirsoft-ecommerce/products?per_page=1');
expect(listResponse.status()).toBe(200);
const listBody = await listResponse.json();
const products = listBody?.data?.data ?? listBody?.data ?? [];
expect(
Array.isArray(products) && products.length > 0,
'판매중인 상품이 없다 — 시드 확인 필요'
).toBe(true);
const detailResponse = await page.request.get(
`/api/modules/sirsoft-ecommerce/products/${products[0].product_code}`
);
expect(detailResponse.status()).toBe(200);
const product = (await detailResponse.json())?.data;
expect(product?.id, '상품 상세 응답에 id 가 없다').toBeTruthy();
const options = product.options ?? [];
const usableOption = options.find((o: any) => (o.stock_quantity ?? 0) > 0) ?? options[0];
if (options.length > 0) {
expect(usableOption, '구매 가능한 옵션이 없다 — 재고 확인 필요').toBeTruthy();
}
const checkoutResponse = await page.request.post('/api/modules/sirsoft-ecommerce/checkout', {
headers,
data: {
direct_items: [
{
product_id: product.id,
quantity: 1,
...(usableOption ? { product_option_id: usableOption.id } : {}),
},
],
},
});
expect(checkoutResponse.status(), '임시주문 생성이 실패했다').toBe(201);
const summary = (await checkoutResponse.json())?.data?.calculation?.summary;
const total = summary?.final_amount ?? summary?.total_amount;
expect(typeof total, '임시주문 응답에 결제 예정 금액이 없다').toBe('number');
return total;
}
/** 임시주문을 만들고 주문서 화면을 연다 (회원 전용 — 저장된 배송지를 적용한다). */
async function openCheckoutScreen(page: Page, token: string): Promise<void> {
const savedAddress = await ensureDefaultAddress(page, token);
await createDirectCheckout(page, token);
await page.goto('/shop/checkout');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await dismissCookieBanner(page);
// 주문서가 임시주문을 실제로 물고 떴는지 확정한다 — 빈 화면 위에서 입력을 채우면
// 뒤따르는 실패가 회귀인지 진입 실패인지 구분되지 않는다.
await expect(page.getByRole('button', { name: /결제하기/ }).first()).toBeVisible({
timeout: 30_000,
});
await applySavedAddress(page, savedAddress);
}
/**
* 값이 비어 있고 편집 가능한 입력만 채운다.
*
* 회원 주문서의 주문자 칸은 계정 값으로 채워진 읽기 전용이라, 비었다고 채우려 들면
* "element is not editable" 로 타임아웃한다(실측). 편집 가능 여부를 먼저 확인한다.
*/
async function fillIfEmpty(page: Page, selector: string, value: string): Promise<void> {
const input = page.locator(selector).first();
if ((await input.count()) === 0) return;
if (!(await input.isEditable().catch(() => false))) return;
const current = await input.inputValue().catch(() => '');
if (current.trim() !== '') return;
await input.fill(value);
}
/** 주문자·배송지 필수 입력을 채운다. 비회원은 조회 비밀번호까지 필요하다. */
async function fillOrdererAndShipping(page: Page, isGuest: boolean): Promise<void> {
await fillIfEmpty(page, 'input[name="orderer_name"]', 'E2E주문자');
await fillIfEmpty(page, 'input[name="orderer_phone"]', '010-1234-5678');
await fillIfEmpty(page, 'input[name="orderer_email"]', 'e2e-dbank@example.com');
if (isGuest) {
await fillIfEmpty(page, 'input[name="guest_lookup_password"]', 'e2eGuest!234');
await fillIfEmpty(page, 'input[name="guest_lookup_password_confirmation"]', 'e2eGuest!234');
}
await fillIfEmpty(page, 'input[name="recipient_name"]', 'E2E수령인');
await fillIfEmpty(page, 'input[name="recipient_phone"]', '010-1234-5678');
await fillIfEmpty(page, 'input[name="zipcode"]', '06236');
await fillIfEmpty(page, 'input[name="address"]', '서울특별시 강남구 테헤란로 1');
await fillIfEmpty(page, 'input[name="address_detail"]', 'E2E 101호');
}
/**
* 결제수단을 무통장입금으로 고르고, 입금 계좌 선택·입금자명까지 채운다.
*
* 계좌는 Select 가 아니라 계좌 목록 버튼이다. 어떤 계좌가 등록돼 있는지는 환경마다 다르므로
* 공개 설정에서 받은 계좌번호로 해당 버튼을 지목한다(문구·순서 하드코딩 회피).
*/
async function chooseBankTransfer(page: Page, accounts: any[]): Promise<void> {
// 결제수단 카드는 화면 아래쪽이라 쿠키 배너가 떠 있으면 클릭이 가로채인다.
await dismissCookieBanner(page);
const method = page.getByText('무통장입금', { exact: true }).first();
await method.scrollIntoViewIfNeeded();
await method.click();
// 무통장 분기가 실제로 열렸는지 먼저 확정한다 — 안 열린 상태에서 하위 입력을 찾으면
// "없음" 이 결함인지 아직 렌더 전인지 구분되지 않는다.
await expect(page.getByText('아래 계좌를 선택하여 입금해주세요.')).toBeVisible({
timeout: 20_000,
});
expect(accounts.length, '사용중인 입금 계좌가 없다 — 주문설정의 계좌 목록 확인 필요').toBeGreaterThan(0);
const accountNumber = String(accounts[0].account_number ?? '').trim();
expect(accountNumber, '계좌 응답에 계좌번호가 없다').not.toBe('');
const accountButton = page.getByRole('button').filter({ hasText: accountNumber }).first();
await expect(accountButton, `계좌 ${accountNumber} 버튼이 없다`).toBeVisible({ timeout: 20_000 });
await accountButton.click();
await fillIfEmpty(page, 'input[name="depositor_name"]', 'E2E입금자');
}
/**
* '결제하기' 를 눌러 주문을 생성하고 응답 상태를 돌려준다.
*
* 회귀의 실패 형태가 **500** 이므로 상태 코드를 직접 본다. 화면 단언만 하면 500 일 때
* "완료 페이지가 아직 안 떴다" 와 구분되지 않는다.
*/
async function placeOrder(page: Page): Promise<{ status: number; body: any }> {
const responsePromise = page.waitForResponse(
(response) => ORDER_CREATE_API.test(response.url()) && response.request().method() === 'POST',
{ timeout: 60_000 }
);
await page.getByRole('button', { name: /결제하기/ }).first().click();
const response = await responsePromise;
const body = await response.json().catch(() => null);
return { status: response.status(), body };
}
/**
* 주문 응답의 입금기한이 주문일 + 설정값인지 확인한다.
*
* 화면 문자열을 비교하면 표기 형식(로케일/타임존)에 묶이므로, 응답의 `ordered_at` 과
* `deposit_due_at` 의 일수 차이로 판정한다.
*/
function expectDueDaysMatch(body: any, days: number): void {
const order = body?.data?.order ?? body?.data ?? body;
const orderedAt = order?.ordered_at ?? order?.created_at;
const dueAt =
order?.payment?.deposit_due_at ??
order?.payment?.due_date ??
order?.deposit_due_at ??
order?.payment?.vbank_due_at;
expect(orderedAt, '응답에 주문일시가 없다').toBeTruthy();
expect(dueAt, '응답에 입금기한이 없다 — 기한 산정 자체가 누락됐다').toBeTruthy();
const diffDays = Math.round(
(new Date(dueAt).getTime() - new Date(orderedAt).getTime()) / 86_400_000
);
expect(diffDays, '입금기한이 주문일 + 설정값과 다르다').toBe(days);
}
/** 완료 화면이 열리고 입금기한 항목이 표시되는지 확인한다. */
async function expectCompletePage(page: Page): Promise<void> {
await expect(page).toHaveURL(/\/shop\/orders\/[^/]+\/complete/, { timeout: 30_000 });
await expect(page.getByText('입금 기한').first()).toBeVisible({ timeout: 30_000 });
}
test.describe('무통장입금 체크아웃 — 입금기한 산정 (숫자 설정 타입 안전성)', () => {
/**
* @scenario payment_method=dbank, setting_value_type=int
*
* @effects settings_read_returns_schema_scalar_type
*/
test('공개 결제설정의 입금기한이 문자열로 내려오지 않는다', async ({ page }) => {
await page.goto('/shop/products');
const { raw } = await readOrderSettings(page);
// 회귀 형태가 "문자열 영속" 이므로 문자열 부재를 직접 단언한다.
expect(typeof raw, `입금기한이 문자열로 내려왔다: ${JSON.stringify(raw)}`).not.toBe('string');
if (raw !== null && raw !== undefined) {
expect(Number.isInteger(raw)).toBe(true);
}
});
/**
* @scenario payment_method=dbank, setting_value_type=int
*
* @effects order_creation_succeeds_with_string_setting,
* deposit_due_at_survives_string_setting
*/
test('회원 무통장 주문이 생성되고 완료 화면에 입금기한이 표시된다', async ({ page, customerToken }) => {
await authenticatePage(page, customerToken);
const { days, accounts } = await readOrderSettings(page);
await openCheckoutScreen(page, customerToken);
await fillOrdererAndShipping(page, false);
await chooseBankTransfer(page, accounts);
const { status, body } = await placeOrder(page);
expect(status, '무통장 주문 생성이 실패했다 (500 이면 기한 산정 회귀)').toBe(201);
expectDueDaysMatch(body, days);
await expectCompletePage(page);
});
/**
* @scenario payment_method=dbank, setting_value_type=int
*
* @effects order_creation_succeeds_with_string_setting,
* deposit_due_at_survives_string_setting
*/
test('비회원 무통장 주문이 생성되고 입금기한이 주문일 + 설정값이다', async ({ page }) => {
// 직전 테스트의 회원 세션·비회원 토큰 잔재를 비운다 — 남아 있으면 비회원 분기가 아예 안 열린다.
await page.goto('/');
await page.evaluate(() => {
try {
localStorage.removeItem('g7_auth_token');
sessionStorage.removeItem('g7_guest_order_token');
sessionStorage.removeItem('g7_guest_order_number');
sessionStorage.removeItem('g7_guest_order_expires_at');
} catch {}
});
const { days, accounts } = await readOrderSettings(page);
expect(accounts.length, '사용중인 입금 계좌가 없다').toBeGreaterThan(0);
const keyResponse = await page.request.post('/api/modules/sirsoft-ecommerce/cart/key', {
headers: { Accept: 'application/json' },
});
expect(keyResponse.status(), '비회원 장바구니 키 발급이 실패했다').toBeLessThan(300);
const cartKey = (await keyResponse.json())?.data?.cart_key;
expect(cartKey, '장바구니 키 응답에 cart_key 가 없다').toBeTruthy();
const total = await createDirectCheckout(page, undefined, cartKey);
// 비회원은 저장된 배송지가 없어 주소를 주소검색 위젯으로만 넣을 수 있다(§상단 주석).
// 그래서 이 케이스는 주문서 화면 대신 같은 브라우저 세션의 주문 생성 요청으로 검증한다 —
// 회귀가 터지는 지점(서버의 입금기한 산정)은 동일하다.
const response = await page.request.post('/api/modules/sirsoft-ecommerce/user/orders', {
headers: { Accept: 'application/json', 'X-Cart-Key': cartKey },
data: {
orderer: { name: 'E2E주문자', phone: '010-1234-5678', email: 'e2e-guest@example.com' },
shipping: {
recipient_name: 'E2E수령인',
recipient_phone: '010-1234-5678',
country_code: 'KR',
zipcode: '06236',
address: '서울특별시 강남구 테헤란로 1',
address_detail: 'E2E 101호',
},
payment_method: 'dbank',
depositor_name: 'E2E입금자',
dbank: {
bank_code: accounts[0].bank_code,
account_number: accounts[0].account_number,
account_holder: accounts[0].account_holder,
},
expected_total_amount: total,
guest_lookup_password: 'e2eGuest!234',
guest_lookup_password_confirmation: 'e2eGuest!234',
},
});
const body = await response.json().catch(() => null);
expect(
response.status(),
`비회원 무통장 주문 생성이 실패했다 (500 이면 기한 산정 회귀): ${JSON.stringify(body)?.slice(0, 300)}`
).toBe(201);
expectDueDaysMatch(body, days);
});
});
@@ -0,0 +1,191 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Http\Requests;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 이커머스 설정 저장 요청의 숫자 필드 캐스트 테스트
*
* HTML number 입력의 DOM 값은 문자열이고 Laravel `integer` 규칙은 숫자 문자열을
* 통과시키되 캐스트하지 않는다. 캐스트가 없으면 문자열이 그대로 영속되어
* 이후 Carbon 등 strict 타입 경계에서 TypeError 가 발생한다.
*
* 캐스트 대상은 rules() 에서 파생한다 — 필드를 손으로 열거하면 규칙이 추가될 때
* 누락(드리프트)이 생기므로, integer/numeric 규칙을 가진 모든 필드가 자동 포함되어야 한다.
*
* @effects request_numeric_fields_cast_before_validation, request_validation_not_loosened
*/
class StoreEcommerceSettingsRequestNumericCastTest extends ModuleTestCase
{
/**
* 요청을 해석하고 검증 통과 데이터를 반환합니다.
*/
private function resolve(array $payload): StoreEcommerceSettingsRequest
{
$request = StoreEcommerceSettingsRequest::create('/', 'POST', $payload);
$request->setContainer($this->app)->setRedirector($this->app['redirect']);
$request->validateResolved();
return $request;
}
public function test_auto_cancel_days_string_is_cast_to_int(): void
{
$request = $this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => '5'],
]);
$this->assertSame(5, $request->validated()['order_settings']['auto_cancel_days']);
}
public function test_zero_padded_numeric_string_is_cast_to_int(): void
{
$request = $this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => '05'],
]);
$this->assertSame(5, $request->validated()['order_settings']['auto_cancel_days']);
}
public function test_wildcard_nested_numeric_fields_are_cast(): void
{
$request = $this->resolve([
'_tab' => 'mileage',
'mileage' => [
'enabled' => true,
'default_earn_rate' => '1',
'currency_rules' => [
['currency_code' => 'KRW', 'point_value' => '1.5', 'use_unit' => '100', 'min_use_amount' => '1000'],
],
],
]);
$rule = $request->validated()['mileage']['currency_rules'][0];
$this->assertSame(1.5, $rule['point_value']);
$this->assertSame(100, $rule['use_unit']);
$this->assertSame(1000, $rule['min_use_amount']);
}
public function test_non_numeric_string_is_not_cast_and_still_fails_validation(): void
{
$this->expectException(ValidationException::class);
$this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => 'abc'],
]);
}
public function test_decimal_string_on_integer_field_still_fails_validation(): void
{
// 소수 문자열을 intval 로 통과시키면 검증이 느슨해진다 — 캐스트 대상에서 제외되어야 한다.
$this->expectException(ValidationException::class);
$this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => '3.7'],
]);
}
public function test_string_fields_are_not_converted_to_numbers(): void
{
$request = $this->resolve([
'_tab' => 'basic_info',
'basic_info' => [
'shop_name' => '테스트샵',
'route_path' => 'shop',
'business_number_1' => '012',
],
]);
$this->assertSame('012', $request->validated()['basic_info']['business_number_1']);
}
/**
* 빈 값은 조용히 null 로 영속되면 안 된다.
*
* 숫자 입력칸에 비숫자를 넣으면 브라우저가 값을 `""` 로 비우고, 그대로 저장하면
* `ConvertEmptyStringsToNull` 로 null 이 되어 `nullable` 을 통과한다. 결과적으로
* 화면에는 "저장되었습니다" 가 뜨지만 실제 값은 사라지고 소비처의 숨은 기본값으로
* 동작해 관리자가 본 것과 실제 동작이 어긋난다.
*/
public function test_empty_auto_cancel_days_is_rejected(): void
{
$this->expectException(ValidationException::class);
$this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => ''],
]);
}
public function test_null_auto_cancel_days_is_rejected(): void
{
$this->expectException(ValidationException::class);
$this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_days' => null],
]);
}
/**
* rules() 는 탭 구분 없이 적용되므로, auto_cancel_days 를 무조건 required 로 두면
* 이 키를 보내지 않는 다른 탭 저장이 통째로 막힌다(마일리지 탭 저장 불가 회귀).
* 필수는 "키가 왔을 때만" 적용되어야 한다.
*/
public function test_other_tab_save_is_not_blocked_by_auto_cancel_days_requirement(): void
{
$request = $this->resolve([
'_tab' => 'mileage',
'mileage' => ['enabled' => true, 'default_earn_rate' => '1'],
]);
$this->assertArrayNotHasKey('order_settings', $request->validated());
}
/**
* 자동취소 스위치를 꺼도 값 자체는 항상 함께 전송되므로(화면 실측 확인),
* 필수화가 정상 저장 흐름을 깨지 않아야 한다.
*/
public function test_valid_value_passes_when_auto_cancel_disabled(): void
{
$request = $this->resolve([
'_tab' => 'order_settings',
'order_settings' => ['auto_cancel_expired' => false, 'auto_cancel_days' => 3],
]);
$this->assertSame(3, $request->validated()['order_settings']['auto_cancel_days']);
}
/**
* rules() 에 integer/numeric 규칙을 가진 필드가 존재하는 한, 요청 정규화는
* 그 목록을 rules() 에서 파생해야 한다(하드코딩 열거 금지 — 드리프트 방지).
*/
public function test_every_integer_rule_field_is_covered_by_derived_casting(): void
{
$rules = (new StoreEcommerceSettingsRequest)->rules();
$numericFields = [];
foreach ($rules as $field => $fieldRules) {
$list = is_array($fieldRules) ? $fieldRules : explode('|', (string) $fieldRules);
foreach ($list as $rule) {
if (is_string($rule) && in_array($rule, ['integer', 'numeric'], true)) {
$numericFields[] = $field;
break;
}
}
}
$this->assertNotEmpty($numericFields, 'integer/numeric 규칙 필드가 하나도 없습니다 (테스트 전제 붕괴).');
// 대표 필드 하나로 파생 캐스트가 실제 동작함을 확인 (전수는 파생 로직 자체가 보장).
$this->assertContains('order_settings.auto_cancel_days', $numericFields);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 이커머스 설정 숫자 타입 정규화 테스트
*
* 관리자 화면이 HTML number 입력의 문자열 값을 저장하더라도, 조회 시에는
* defaults.json 스키마의 스칼라 타입(int/float)으로 정규화되어야 한다.
* 정규화가 없으면 Carbon 등 strict 타입 경계에서 TypeError 가 발생한다.
*
* @effects settings_read_returns_schema_scalar_type
*/
class EcommerceSettingsNumericTypeTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
$this->service = new EcommerceSettingsService;
}
protected function tearDown(): void
{
if (File::isDirectory($this->storagePath)) {
File::cleanDirectory($this->storagePath);
}
parent::tearDown();
}
public function test_auto_cancel_days_saved_as_string_is_read_back_as_int(): void
{
$this->service->saveSettings(['order_settings' => ['auto_cancel_days' => '5']]);
$this->assertSame(5, $this->service->getSetting('order_settings.auto_cancel_days'));
}
public function test_cart_expiry_days_saved_as_string_is_read_back_as_int(): void
{
$this->service->saveSettings(['order_settings' => ['cart_expiry_days' => '15']]);
$this->assertSame(15, $this->service->getSetting('order_settings.cart_expiry_days'));
}
public function test_int_saved_value_stays_int(): void
{
$this->service->saveSettings(['order_settings' => ['auto_cancel_days' => 7]]);
$this->assertSame(7, $this->service->getSetting('order_settings.auto_cancel_days'));
}
public function test_boolean_setting_is_not_affected_by_numeric_normalization(): void
{
$this->service->saveSettings(['order_settings' => ['auto_cancel_expired' => false]]);
$this->assertFalse($this->service->getSetting('order_settings.auto_cancel_expired'));
}
public function test_string_setting_is_not_converted_to_number(): void
{
// defaults 타입이 문자열인 설정은 값이 숫자처럼 보여도 문자열을 유지해야 한다.
$this->service->saveSettings(['basic_info' => ['business_number' => '0123456789']]);
$this->assertSame('0123456789', $this->service->getSetting('basic_info.business_number'));
}
}
@@ -0,0 +1,343 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Contracts\Extension\ModuleInterface;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\ModuleSettingsInterface;
use App\Services\ModuleSettingsService;
use Illuminate\Support\Carbon;
use Mockery;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
use Modules\Sirsoft\Ecommerce\DTO\Summary;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
/**
* ModuleInterface + ModuleSettingsInterface 결합 스텁 (타입 안전성 테스트 전용)
*/
abstract class OrderProcessingTypeSafetyModuleStub implements ModuleInterface, ModuleSettingsInterface {}
/**
* 입금기한 산정의 설정값 타입 안전성 회귀 테스트
*
* auto_cancel_days 가 문자열("3")·빈 문자열·null·범위 밖 값으로 저장되어 있어도
* 무통장입금(vbank/dbank) 주문 생성이 Carbon TypeError 없이 성공해야 한다.
*
* 배경: Carbon 3.x 의 rawAddUnit(int|float $value) 는 strict_types 파일에서 호출되므로
* 숫자 문자열이 강제변환되지 않고 TypeError 를 던진다. 관리자 환경설정을 한 번만
* 저장해도 HTML number 입력의 문자열 값이 그대로 영속되어 전 무통장입금 주문이 500 실패했다.
*/
class OrderProcessingDueAtTypeSafetyTest extends ModuleTestCase
{
protected OrderProcessingService $service;
private array $moduleSettings = [];
protected function setUp(): void
{
parent::setUp();
$this->moduleSettings = [];
$this->mockModuleSetting();
$this->service = app(OrderProcessingService::class);
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
/**
* 설정 조회를 스텁으로 대체합니다 (저장 타입을 그대로 반환 — 정규화 계층 우회).
*/
private function mockModuleSetting(): void
{
$mockModule = $this->createMock(OrderProcessingTypeSafetyModuleStub::class);
$mockModule->method('getSetting')
->willReturnCallback(function (string $key, mixed $default = null) {
return array_key_exists($key, $this->moduleSettings)
? $this->moduleSettings[$key]
: $default;
});
$mockModuleManager = $this->createMock(ModuleManagerInterface::class);
$mockModuleManager->method('getModule')
->with('sirsoft-ecommerce')
->willReturn($mockModule);
$this->app->instance(ModuleManagerInterface::class, $mockModuleManager);
$this->app->forgetInstance(ModuleSettingsService::class);
$mockEcommerceSettings = Mockery::mock(EcommerceSettingsService::class)->makePartial();
$mockEcommerceSettings->shouldReceive('getSetting')
->andReturnUsing(function (string $key, mixed $default = null) {
return array_key_exists($key, $this->moduleSettings)
? $this->moduleSettings[$key]
: $default;
});
$this->app->instance(EcommerceSettingsService::class, $mockEcommerceSettings);
}
private function makeCalculationResult(int $finalAmount = 50000): OrderCalculationResult
{
$summary = new Summary(
subtotal: $finalAmount,
totalDiscount: 0,
productCouponDiscount: 0,
codeDiscount: 0,
totalShipping: 0,
taxableAmount: $finalAmount,
taxFreeAmount: 0,
pointsUsed: 0,
pointsEarning: 0,
paymentAmount: $finalAmount,
finalAmount: $finalAmount,
);
return new OrderCalculationResult(
items: [],
summary: $summary,
promotions: new PromotionsSummary,
validationErrors: [],
);
}
private function invokeCreatePayment(Order $order, string $method, ?array $dbankInfo = null): void
{
$reflection = new \ReflectionClass($this->service);
$m = $reflection->getMethod('createOrderPayment');
$m->invoke($this->service, $order, $method, '홍길동', $dbankInfo, $this->makeCalculationResult(), []);
}
private function dbankInfo(): array
{
return ['bank_code' => '004', 'account_number' => '123', 'account_holder' => '홍길동'];
}
/**
* 정상 정수로 저장된 경우 (회귀 기준선).
*
* @return array<string, array{0: mixed, 1: int}>
*/
public static function intValueProvider(): array
{
return [
'정수 3 (기본값과 동일)' => [3, 3],
'정수 5' => [5, 5],
];
}
/**
* 숫자 문자열로 저장된 경우 — 크래시 원인이던 형태 + 상한 클램프.
*
* @return array<string, array{0: mixed, 1: int}>
*/
public static function numericStringValueProvider(): array
{
return [
'숫자 문자열 "3"' => ['3', 3],
'앞자리 0 문자열 "05"' => ['05', 5],
'상한 초과 "999"' => ['999', 30],
];
}
/**
* 사용할 수 없는 값 — 전부 기본값(3)으로 되돌아가야 한다.
*
* @return array<string, array{0: mixed, 1: int}>
*/
public static function degenerateValueProvider(): array
{
return [
'빈 문자열' => ['', 3],
'null' => [null, 3],
'0' => [0, 3],
'문자열 "0"' => ['0', 3],
'음수 문자열 "-5"' => ['-5', 3],
'비숫자 문자열' => ['abc', 3],
];
}
/**
* vbank_due_at 을 검증합니다.
*
* @param mixed $stored 저장 형태 그대로의 설정값
* @param int $expectedDays 기대 입금기한(일)
*/
private function assertVbankDueDays(mixed $stored, int $expectedDays): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0));
$this->moduleSettings = ['order_settings.auto_cancel_days' => $stored];
$order = Order::factory()->create();
$this->invokeCreatePayment($order, 'vbank');
$payment = $order->payment()->first();
$this->assertNotNull($payment->vbank_due_at);
$this->assertSame(
Carbon::now()->addDays($expectedDays)->toDateString(),
Carbon::parse($payment->vbank_due_at)->toDateString(),
);
}
/**
* deposit_due_at 을 검증합니다.
*
* @param mixed $stored 저장 형태 그대로의 설정값
* @param int $expectedDays 기대 입금기한(일)
*/
private function assertDbankDueDays(mixed $stored, int $expectedDays): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0));
$this->moduleSettings = ['order_settings.auto_cancel_days' => $stored];
$order = Order::factory()->create();
$this->invokeCreatePayment($order, 'dbank', $this->dbankInfo());
$payment = $order->payment()->first();
$this->assertNotNull($payment->deposit_due_at);
$this->assertSame(
Carbon::now()->addDays($expectedDays)->toDateString(),
Carbon::parse($payment->deposit_due_at)->toDateString(),
);
}
/**
* @scenario payment_method=vbank, setting_value_type=int
*
* @effects vbank_due_at_survives_string_setting
*/
#[DataProvider('intValueProvider')]
public function test_vbank_due_at_with_int_setting(mixed $stored, int $expectedDays): void
{
$this->assertVbankDueDays($stored, $expectedDays);
}
/**
* @scenario payment_method=vbank, setting_value_type=numeric_string
*
* @effects vbank_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
#[DataProvider('numericStringValueProvider')]
public function test_vbank_due_at_with_numeric_string_setting(mixed $stored, int $expectedDays): void
{
$this->assertVbankDueDays($stored, $expectedDays);
}
/**
* @scenario payment_method=vbank, setting_value_type=degenerate
*
* @effects vbank_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
#[DataProvider('degenerateValueProvider')]
public function test_vbank_due_at_with_unusable_setting(mixed $stored, int $expectedDays): void
{
$this->assertVbankDueDays($stored, $expectedDays);
}
/**
* @scenario payment_method=dbank, setting_value_type=int
*
* @effects deposit_due_at_survives_string_setting
*/
#[DataProvider('intValueProvider')]
public function test_dbank_due_at_with_int_setting(mixed $stored, int $expectedDays): void
{
$this->assertDbankDueDays($stored, $expectedDays);
}
/**
* @scenario payment_method=dbank, setting_value_type=numeric_string
*
* @effects deposit_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
#[DataProvider('numericStringValueProvider')]
public function test_dbank_due_at_with_numeric_string_setting(mixed $stored, int $expectedDays): void
{
$this->assertDbankDueDays($stored, $expectedDays);
}
/**
* @scenario payment_method=dbank, setting_value_type=degenerate
*
* @effects deposit_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
#[DataProvider('degenerateValueProvider')]
public function test_dbank_due_at_with_unusable_setting(mixed $stored, int $expectedDays): void
{
$this->assertDbankDueDays($stored, $expectedDays);
}
/**
* 문자열 설정에서도 클라이언트가 보낸 due_days 는 여전히 무시되어야 한다 (E6 비회귀).
*
* @effects client_due_days_is_ignored
*/
public function test_dbank_still_ignores_client_due_days_when_setting_is_string(): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0));
$this->moduleSettings = ['order_settings.auto_cancel_days' => '3'];
$order = Order::factory()->create();
// 클라이언트가 문자열로 보내도(정수와 동일하게) 무시되어야 한다.
$this->invokeCreatePayment($order, 'dbank', $this->dbankInfo() + ['due_days' => '5']);
$payment = $order->payment()->first();
$this->assertSame(
Carbon::now()->addDays(3)->toDateString(),
Carbon::parse($payment->deposit_due_at)->toDateString(),
);
}
/**
* 설정 키 자체가 없는 환경(신규 설치·구버전 설정 파일)에서도 기본값으로 동작해야 한다.
*
* 값이 null 인 경우와 달리, 키가 아예 없으면 조회가 default 인자로 폴백한다 —
* 별도 경로이므로 따로 고정한다.
*
* @scenario payment_method=dbank, setting_value_type=degenerate
*
* @effects deposit_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
public function test_dbank_due_at_falls_back_when_setting_key_is_absent(): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0));
$this->moduleSettings = []; // 키 자체가 없음
$order = Order::factory()->create();
$this->invokeCreatePayment($order, 'dbank', $this->dbankInfo());
$payment = $order->payment()->first();
$this->assertSame(
Carbon::now()->addDays(3)->toDateString(),
Carbon::parse($payment->deposit_due_at)->toDateString(),
);
}
/**
* vbank 도 동일하게 설정 키 부재 시 기본값으로 동작한다.
*
* @scenario payment_method=vbank, setting_value_type=degenerate
*
* @effects vbank_due_at_survives_string_setting, due_days_clamped_into_allowed_range
*/
public function test_vbank_due_at_falls_back_when_setting_key_is_absent(): void
{
Carbon::setTestNow(Carbon::create(2026, 6, 21, 12, 0, 0));
$this->moduleSettings = []; // 키 자체가 없음
$order = Order::factory()->create();
$this->invokeCreatePayment($order, 'vbank');
$payment = $order->payment()->first();
$this->assertSame(
Carbon::now()->addDays(3)->toDateString(),
Carbon::parse($payment->vbank_due_at)->toDateString(),
);
}
}
@@ -0,0 +1,125 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* PG 플러그인이 선언하는 supported_methods 어휘 정합 테스트
*
* 관리자 결제수단 설정의 PG사 드롭다운은 `supported_methods.includes($method.id)` 로
* 후보를 거른다. 즉 PG 플러그인이 선언하는 값은 반드시 **결제수단 id 어휘**여야 한다.
* 다른 어휘(예: 'virtual_account')로 선언하면 교집합이 비어 드롭다운이 영구히 `---` 만
* 노출되고, 그 결제수단은 기본 PG사 상속 외에는 PG를 지정할 수 없게 된다.
* 백엔드는 결제수단별 pg_provider 를 실제로 사용하므로(OrderProcessingService 의
* `$methodConfig['pg_provider'] ?? default_pg_provider`) 이는 기능 손실이다.
*
* 어휘 목록을 손으로 열거하면 플러그인이 늘어날 때 누락되므로, 저장소의 PG 플러그인
* 선언을 전수 스캔해 검증한다.
*
* @effects pg_supported_methods_use_payment_method_vocabulary
*/
class PgProviderSupportedMethodsVocabularyTest extends ModuleTestCase
{
/**
* 저장소의 모든 PG 플러그인에서 supported_methods 선언을 수집합니다.
*
* @return array<string, array<int, string>> 파일 경로 => 선언된 결제수단 값 목록
*/
private function collectDeclarations(): array
{
$roots = [base_path('plugins/_bundled'), base_path('plugins')];
$found = [];
foreach ($roots as $root) {
if (! is_dir($root)) {
continue;
}
$it = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS)
);
foreach ($it as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$contents = file_get_contents($file->getPathname());
if ($contents === false || ! str_contains($contents, 'supported_methods')) {
continue;
}
if (! preg_match("/'supported_methods'\s*=>\s*\[(.*?)\]/s", $contents, $m)) {
continue;
}
preg_match_all("/'([^']+)'/", $m[1], $values);
if ($values[1] !== []) {
// 활성 디렉토리와 _bundled 중복 수집을 피하기 위해 상대 경로 키를 정규화한다.
$key = str_replace('\\', '/', $file->getPathname());
$found[$key] = $values[1];
}
}
}
return $found;
}
public function test_declared_supported_methods_use_payment_method_ids(): void
{
$declarations = $this->collectDeclarations();
$this->assertNotEmpty(
$declarations,
'PG 플러그인의 supported_methods 선언을 하나도 찾지 못했습니다 (테스트 전제 붕괴 — 스캔 경로/패턴 확인 필요).'
);
$known = array_map(
static fn (PaymentMethodEnum $case): string => $case->value,
PaymentMethodEnum::cases()
);
$violations = [];
foreach ($declarations as $path => $methods) {
foreach ($methods as $method) {
if (! in_array($method, $known, true)) {
$violations[] = sprintf('%s → %s', $path, $method);
}
}
}
$this->assertSame(
[],
$violations,
"PG 플러그인이 결제수단 id 가 아닌 어휘를 선언했습니다.\n".
"이 값은 관리자 PG 드롭다운 필터(`supported_methods.includes(method.id)`)와 직접 대조되므로,\n".
"어휘가 어긋나면 해당 결제수단은 PG를 지정할 수 없습니다.\n".
'허용 어휘: '.implode(', ', $known)."\n위반:\n ".implode("\n ", $violations)
);
}
/**
* PG 가 필요한 결제수단이 최소 한 곳의 PG 플러그인에서 지원 선언되어야,
* 관리자 화면에서 그 결제수단에 PG를 지정할 수 있다.
*/
public function test_pg_requiring_builtin_methods_are_covered_by_some_provider(): void
{
$declared = [];
foreach ($this->collectDeclarations() as $methods) {
foreach ($methods as $m) {
$declared[$m] = true;
}
}
// 무통장입금(dbank)·포인트·예치금·무료는 PG 불필요라 대상이 아니다.
foreach ([PaymentMethodEnum::CARD, PaymentMethodEnum::VBANK, PaymentMethodEnum::BANK, PaymentMethodEnum::PHONE] as $case) {
$this->assertArrayHasKey(
$case->value,
$declared,
"결제수단 '{$case->value}' 를 지원한다고 선언한 PG 플러그인이 없습니다 → 관리자 화면에서 PG 지정 불가."
);
}
}
}
@@ -0,0 +1,77 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Support;
use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 리뷰 작성 기한 정책 SSoT 검증
*
* 같은 규칙이 서비스(저장 가능 여부)와 리소스(화면 표시)에 각각 구현돼 있어
* 조회 방식과 기본값 출처가 서로 달랐다. 특히 서비스 쪽 config 폴백은 모듈 config
* 네임스페이스를 잘못 참조해(`ecommerce.*`) 항상 null 로 해석되고 있었다.
*
* @effects review_deadline_uses_single_policy_source
*/
class ReviewWritePolicyTest extends ModuleTestCase
{
/**
* 모듈 config 네임스페이스가 실제로 해석된다. (기존 폴백은 null 이었다)
*/
public function test_module_config_namespace_resolves(): void
{
$this->assertNull(config('ecommerce.review.write_deadline_days'));
$this->assertNotNull(config('sirsoft-ecommerce.review.write_deadline_days'));
}
/**
* 설정 미지정 시 config 기본값을 사용한다.
*/
public function test_deadline_days_falls_back_to_module_config(): void
{
$this->assertSame(
(int) config('sirsoft-ecommerce.review.write_deadline_days'),
ReviewWritePolicy::deadlineDays()
);
}
/**
* 기한 내 구매확정은 통과한다.
*/
public function test_deadline_not_passed_within_period(): void
{
$this->assertFalse(ReviewWritePolicy::isDeadlinePassed(now()->subDay()));
}
/**
* 기한을 넘긴 구매확정은 차단된다.
*/
public function test_deadline_passed_after_period(): void
{
$days = ReviewWritePolicy::deadlineDays();
$this->assertTrue(ReviewWritePolicy::isDeadlinePassed(now()->subDays($days + 1)));
}
/**
* 미확정(null)은 기한 판정 대상이 아니다.
*/
public function test_null_confirmed_at_is_not_deadline_passed(): void
{
$this->assertFalse(ReviewWritePolicy::isDeadlinePassed(null));
}
/**
* 판정이 인자를 변형하지 않는다. (copy 누락 시 호출자의 시각이 밀린다)
*/
public function test_confirmed_at_is_not_mutated(): void
{
$confirmedAt = now()->subDay();
$snapshot = $confirmedAt->copy();
ReviewWritePolicy::isDeadlinePassed($confirmedAt);
$this->assertTrue($snapshot->equalTo($confirmedAt));
}
}
@@ -0,0 +1,100 @@
feature: 숫자 설정의 문자열 영속에 대한 타입 안전성 (입금기한 크래시 수정)
description: >
관리자 환경설정을 저장하면 HTML number 입력의 문자열 값이 그대로 영속되고, Laravel 의
integer 규칙은 숫자 문자열을 통과시키되 캐스트하지 않는다. 그 값이 Carbon 의 날짜 연산
(strict_types 파일의 rawAddUnit(int|float)) 에 닿으면 TypeError 가 발생해 모든 무통장입금·
가상계좌 주문이 500 으로 실패했다.
방어는 4계층이다 — ① 싱크에서 정수 보장 + 허용 범위 클램프, ② 요청 경계에서 rules() 파생
캐스트(하드코딩 열거 금지), ③ 설정 조회 시 defaults 스키마 타입으로 정규화(전 확장 공통),
④ 모델 casts 로 컬럼 타입 통일.
axes 는 입금기한 산정 경로에만 둔다 — 이 경로만 저장 타입 × 결제수단의 실제 조합을 가지며,
나머지 세 계층은 조합이 아니라 독립 표면이라 sub_flows 로 분리한다.
test_files:
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingDueAtTypeSafetyTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/OrderControllerTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsNumericTypeTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Requests/StoreEcommerceSettingsRequestNumericCastTest.php
- tests/Unit/Traits/NormalizesSettingsDataTest.php
- modules/_bundled/sirsoft-board/tests/Unit/Models/NewDisplayHoursCastTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/shop/dbank-checkout-deposit-due.spec.ts
- modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CouponValidDaysIntegrityTest.php
- modules/_bundled/sirsoft-ecommerce/tests/Unit/Support/ReviewWritePolicyTest.php
# 입력 axis — 입금기한 산정에 영향을 주는 변수
axes:
# 입금기한을 기록하는 두 결제수단 (산정 출처는 auto_cancel_days 단일 SSoT)
payment_method: [vbank, dbank]
# auto_cancel_days 가 설정 파일에 저장된 형태
# - int : 정상 정수 (회귀 기준선)
# - numeric_string : "3" / "05" / "999" — 크래시 원인 형태 + 상한 클램프
# - degenerate : "" / null / 0 / "0" / "-5" / "abc" — 전부 기본값(3)으로 복귀
setting_value_type: [int, numeric_string, degenerate]
effects:
# 입금기한 산정
- vbank_due_at_survives_string_setting
- deposit_due_at_survives_string_setting
- due_days_clamped_into_allowed_range # 상한 초과/사용 불가 값 → 허용 범위·기본값 복귀
- client_due_days_is_ignored # 요청 due_days 무시 (E6 비회귀)
- order_creation_succeeds_with_string_setting # HTTP 레벨 — 회원·비회원 dbank + 비회원 vbank
# 요청 경계
- request_numeric_fields_cast_before_validation
- request_validation_not_loosened # 소수/비숫자는 캐스트 제외 → 검증 실패 유지
# 설정 조회 정규화 (전 확장 공통 계층)
- settings_read_returns_schema_scalar_type
# 모델 캐스트
- board_new_display_hours_is_integer
# nullable 컬럼이 Carbon 경계에 닿는 경로 (설정이 아니라 모델 속성 출처)
- coupon_update_rejects_days_from_issue_without_valid_days
- coupon_issue_blocked_when_validity_not_configured
# 같은 설정을 읽는 두 소비처의 판정 기준 통일
- review_deadline_uses_single_policy_source
sub_flows:
- id: settings_request_boundary
description: 관리자 설정 저장 요청에서 rules() 파생 숫자 캐스트 (와일드카드 중첩 경로 포함)
effects:
- request_numeric_fields_cast_before_validation
- request_validation_not_loosened
- id: settings_read_normalization
description: 코어 트레이트가 defaults 스키마 스칼라 타입으로 정규화 (모듈/플러그인 공통)
effects:
- settings_read_returns_schema_scalar_type
- id: model_cast_consistency
description: 숫자 컬럼을 모델 casts 로 통일해 조회 시점과 무관하게 정수 보장
effects:
- board_new_display_hours_is_integer
- id: nullable_column_at_carbon_boundary
description: >
값의 출처가 설정이 아니라 nullable 모델 속성인 경로. 컬럼 캐스트('integer')는 NULL 을
보존하고 Carbon 은 NULL 을 0 으로 흡수하므로 예외 없이 "기간 0" 이 된다 — 쿠폰이
발급 즉시 만료 상태로 나갔다. 저장 단계에서 조합을 막고, 이미 저장된 데이터는
발급 관문에서 명시 거절한다.
effects:
- coupon_update_rejects_days_from_issue_without_valid_days
- coupon_issue_blocked_when_validity_not_configured
- id: shared_setting_single_policy
description: >
같은 설정(리뷰 작성 기한)을 화면 표시와 저장 판정이 각각 읽으면서 조회 방식·기본값
출처가 달랐다. 판정을 단일 정책 헬퍼로 모아 두 소비처가 같은 값을 쓰게 한다.
effects:
- review_deadline_uses_single_policy_source
- id: browser_checkout_completion
description: >
브라우저에서 무통장 결제까지 이어지는 흐름 — 결함이 처음 드러난 경로다.
회원은 주문서 화면에서 결제수단 선택 → 결제하기까지 실제로 몰고, 비회원은 저장된 배송지가
없어 주소 칸(readOnly)을 외부 주소검색 위젯으로만 채울 수 있으므로 같은 브라우저 세션의
주문 생성 요청으로 검증한다(비회원 주문서 화면 실측은 Chrome MCP 매트릭스 D2 로 확보).
effects:
- order_creation_succeeds_with_string_setting
- deposit_due_at_survives_string_setting
- settings_read_returns_schema_scalar_type
@@ -8,6 +8,7 @@
### Fixed
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
- 카드결제를 거치지 않은 주문(무통장입금 등)의 완료 화면에서도 영수증 조회를 시도해 불필요한 요청이 나가던 문제를 수정했습니다. 화면에는 영향이 없었으나 이제 카드결제 주문에서만 조회합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- PC 결제창 요청의 최소 결제금액이 100원으로 제한되어, 모바일에서는 성립하는 소액 결제가 PC 에서만 거부되던 문제를 수정했습니다. 이제 PC·모바일이 동일한 기준을 사용합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -67,7 +67,10 @@ class RegisterPgProviderListener implements HookListenerInterface
'name_key' => 'sirsoft-pay_kginicis::provider.name',
'name' => localized_label(nameKey: 'sirsoft-pay_kginicis::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 결제수단 id 어휘(PaymentMethodEnum)로 선언한다 — 관리자 PG 드롭다운이
// supported_methods.includes(method.id) 로 대조하므로 다른 어휘를 쓰면
// 그 결제수단은 PG를 지정할 수 없게 된다(무통장입금 dbank 는 PG 불필요라 제외).
'supported_methods' => ['card', 'bank', 'vbank', 'phone'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
@@ -37,15 +37,23 @@ abstract class PluginTestCase extends TestCase
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => $this->shouldSeed(),
'--seeder' => $this->seeder(),
'--path' => [
'database/migrations',
'modules/sirsoft-ecommerce/database/migrations',
],
'--path' => $paths,
];
}
@@ -8,6 +8,7 @@
### Fixed
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다.
@@ -53,7 +53,10 @@ class RegisterPgProviderListener implements HookListenerInterface
'name_key' => 'sirsoft-pay_nhnkcp::provider.name',
'name' => localized_label(nameKey: 'sirsoft-pay_nhnkcp::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 결제수단 id 어휘(PaymentMethodEnum)로 선언한다 — 관리자 PG 드롭다운이
// supported_methods.includes(method.id) 로 대조하므로 다른 어휘를 쓰면
// 그 결제수단은 PG를 지정할 수 없게 된다(무통장입금 dbank 는 PG 불필요라 제외).
'supported_methods' => ['card', 'bank', 'vbank', 'phone'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
@@ -33,15 +33,23 @@ abstract class PluginTestCase extends TestCase
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => $this->shouldSeed(),
'--seeder' => $this->seeder(),
'--path' => [
'database/migrations',
'modules/sirsoft-ecommerce/database/migrations',
],
'--path' => $paths,
];
}
@@ -8,6 +8,7 @@
### Fixed
- 관리자 결제수단 설정에서 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제를 수정했습니다. 해당 결제수단의 PG사 목록이 항상 비어 있어 기본 PG사만 사용할 수 있었고, 결제수단별로 다른 PG사를 지정하는 설정이 화면에서 불가능했습니다.
- 관리자 주문 목록에서 주문번호를 눌러 상세로 들어갈 때 걸어 둔 필터와 페이지가 사라지던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 관리자 주문 목록에서 '이 주문자의 주문 검색'을 실행하면 검색창은 비어 보이는데 직전 검색어가 계속 적용돼 결과가 어긋나던 문제를 수정했습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 설정 저장에 실패하면 서버가 알려 준 사유가 그대로 표시됩니다.
@@ -51,7 +51,10 @@ class RegisterPgProviderListener implements HookListenerInterface
'name_key' => 'sirsoft-pay_nicepayments::provider.name',
'name' => localized_label(nameKey: 'sirsoft-pay_nicepayments::provider.name'),
'icon' => 'credit-card',
'supported_methods' => ['card', 'bank_transfer', 'virtual_account', 'mobile'],
// 결제수단 id 어휘(PaymentMethodEnum)로 선언한다 — 관리자 PG 드롭다운이
// supported_methods.includes(method.id) 로 대조하므로 다른 어휘를 쓰면
// 그 결제수단은 PG를 지정할 수 없게 된다(무통장입금 dbank 는 PG 불필요라 제외).
'supported_methods' => ['card', 'bank', 'vbank', 'phone'],
// 코어의 provider-agnostic 결제 진입 dispatch — 주문 응답의 pg_payment_handler 로
// 내려가 템플릿이 그대로 호출한다. 미선언 시 프론트가 응답을 변조해 결제창을
// 직접 띄우는 우회가 필요해진다(#475).
@@ -33,15 +33,23 @@ abstract class PluginTestCase extends TestCase
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => $this->shouldSeed(),
'--seeder' => $this->seeder(),
'--path' => [
'database/migrations',
'modules/sirsoft-ecommerce/database/migrations',
],
'--path' => $paths,
];
}
@@ -32,14 +32,22 @@ abstract class PluginTestCase extends TestCase
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => [
'database/migrations',
'plugins/sirsoft-verification_kginicis/database/migrations',
],
'--path' => $paths,
];
}
@@ -45,14 +45,22 @@ abstract class PluginTestCase extends TestCase
*/
protected function migrateFreshUsing(): array
{
// 모든 번들 확장 migrations 포함 — 여러 확장 스위트를 한 프로세스에서 함께 돌릴 때
// 가장 먼저 실행된 TestCase 가 스키마를 확정하므로, 자기 확장만 넘기면 뒤따르는
// 확장의 테이블이 생성되지 않는다 (troubleshooting-backend.md 사례 21).
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => [
'database/migrations',
$this->pluginRelativePath().'/database/migrations',
],
'--path' => $paths,
];
}
File diff suppressed because one or more lines are too long
@@ -5,6 +5,33 @@
>
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
## [engine-v1.54.8] - 2026-08-01
### Fixed
#### `_localInit` prune 분기가 제거할 것이 없어도 매번 상태 갱신 + 캐시 무효화를 하던 문제
- `DynamicRenderer.tsx` `removeMatchingLeafKeys` — 제거 대상이 없어도 경로상의 객체를 항상 새로 만들어 반환했다. 호출부는 참조 비교로 "실제로 제거된 것이 있는가" 를 판정하므로, 중첩 경로가 겹치기만 하면(예: 저장소 A 에 `form.theme`, payload 에 `form.auto_cancel_days`) 내용이 동일한데도 판정이 참이 되어 `setLocalDynamicState` 와 `invalidateCacheByKeys(['_local'])` 가 상시 실행됐다. engine-v1.54.7 이 "실제 제거된 경우에만" 무효화하도록 명시한 조건이 사실상 항상 참이었던 셈이다.
- 수정: 제거가 하나도 없으면 **원본 참조를 그대로 반환**한다. 제거가 일어난 경우에도 변경되지 않은 형제 가지는 참조를 보존하므로, 불필요한 하위 리렌더도 함께 줄어든다. 다른 호출 지점(`setLocal` 정리 경로)도 같은 이득을 받으며, 반환값을 변형하는 호출부는 없다(전수 확인).
- prune 분기의 상태 쓰기를 updater 형태(`prev => ...`)로 바꿨다. 같은 commit 에서 `useLayoutEffect` 가 큐에 넣은 제거가 아직 반영되기 전일 수 있어, 커밋 시점 스냅샷을 직접 쓰면 그 제거를 되살릴 수 있었다. 캐시 무효화 판정은 종전대로 커밋된 값 기준 동기 계산이다(updater 안의 플래그는 StrictMode 이중 호출·배치 지연으로 신뢰할 수 없음).
- 동작 계약 무변경: 제거 대상이 실제로 있을 때의 결과 상태·무효화·병합 우선순위는 이전과 동일하다.
## [engine-v1.54.7] - 2026-08-01
### Fixed
#### 탭 왕복 후 먼저 편집한 입력칸에 옛 입력값이 남아 화면과 실제 저장값이 어긋나던 문제
- `DynamicRenderer.tsx` `_localInit` useEffect — 적용 여부를 전역 해시(`__g7LocalInitTracking`)만으로 판정했다. 그런데 실제 리셋 대상인 `localDynamicState`(저장소 A)는 **렌더러 인스턴스별**이라, 먼저 effect 가 도는 인스턴스가 전역 토큰을 소비하면 나머지 루트 렌더러의 저장소 A 는 영원히 리셋되지 않았다. 병합은 A 우선(`deepMergeState(dataContext._local, dynamicState)`)이므로 갱신된 저장소 B 위에 stale A 가 덮였다.
- 증상: 폼 데이터소스가 `initLocal` + `refetchOnMount: true` 이고 탭 전환이 URL 을 바꿔 remount + refetch 를 유발하는 화면에서, 되돌아오기 전 입력칸을 2개 이상 편집하면 **먼저 편집한 칸에 사용자가 친 값이 남는다**(마지막 편집 칸은 정상 복귀). 저장값은 서버값이라 화면 표시와 실제 값이 어긋나며, 새로고침해야 드러난다. 콘솔 에러 0건의 조용한 실패다.
- 재현은 **SPA 라우팅(탭 클릭)에서만** 된다. 주소창 이동·새로고침은 모든 렌더러를 새로 마운트하고 전역 추적도 초기화하므로 수정 전에도 증상이 나타나지 않는다 — E2E 로 이 계열을 잡을 때 전체 새로고침 왕복을 쓰면 false green 이 된다(실측).
- 측정 범위: "먼저 편집"은 필요조건이지 충분조건이 아니다. 통제 실험에서 A(먼저)→B(나중) 순서는 A 가 어긋났고, 순서를 뒤집은 B(먼저)→A(나중) 는 둘 다 정상이었다. 잔존이 화면까지 드러나려면 그 입력칸을 소유한 렌더러 인스턴스가 저장소 A 리셋을 건너뛴 쪽이어야 하기 때문이다.
- 순서 의존성의 출처: 자동바인딩 `performStateUpdate` 는 키입력마다 병합된 `_local` **전체 스냅샷**을 저장소 A 에 쓰는데, 뒤따르는 `removeMatchingLeafKeys` 정리는 `__g7SetLocalOverrideKeys` 에 남은 **마지막 leaf 만** 지운다. 이 전역 플래그는 `queueMicrotask` 로 클리어되므로 다음 필드를 칠 때 직전 필드 키는 이미 사라져 있다.
- 수정: 판정을 `localInitSlot.ts` 의 `resolveLocalInitAction` 으로 분리하고 `apply` / `prune` / `skip` 3분기로 확장했다. 다른 인스턴스가 이미 적용한 payload 를 만난 인스턴스는 자기 저장소 A 에서 **payload 키 공간만 제거**한다 — 값을 다시 쓰지 않는다. 제거하면 그 자리에 이미 갱신된 저장소 B 가 그대로 비쳐 보인다.
- 재적용이 아니라 제거인 이유: 늦게 마운트된 인스턴스가 소비된 과거 payload 를 저장소 B 에 되쓰면 그 사이의 사용자 편집이 되돌아간다(`mergeLocalInitSlot` 의 `consumed → 교체` 규칙이 막고 있는 회귀). 제거는 값을 도입하지 않으므로 이 위험이 구조적으로 없고, 저장소 A 가 비어 있는 신규 마운트 인스턴스에서는 no-op 이다.
- 실제로 키가 제거된 경우에만 `invalidateCacheByKeys(['_local'])` 를 호출한다. 병합 결과가 바뀌는데 단순 경로 바인딩은 캐시 대상이라 무효화하지 않으면 화면이 갱신되지 않고, 반대로 무조건 무효화하면 불필요한 재평가가 생긴다.
- 기존 동작 무영향: 전역 추적 구조·자동바인딩 쓰기 경로(`performStateUpdate`)·병합 우선순위·리렌더 전략 어느 것도 변경하지 않는다. `_localInit` payload 가 없는 화면은 분기에 진입하지 않는다.
## [engine-v1.54.6] - 2026-08-01
### Fixed
@@ -29,7 +29,7 @@ import { hasPipes } from './PipeRegistry';
import { RAW_PREFIX, RAW_MARKER_START, RAW_MARKER_END, RAW_PLACEHOLDER_MARKER, isRawWrapped, unwrapRaw, containsRawMarker, wrapRawDeep } from './rawMarkers';
import type { ConditionsProperty } from './helpers/ConditionEvaluator';
import { useTransitionState } from './TransitionContext';
import { getLocalInitTracking, markLocalInitConsumed } from './localInitSlot';
import { getLocalInitTracking, markLocalInitConsumed, resolveLocalInitAction } from './localInitSlot';
import { useResponsive } from './ResponsiveContext';
import { createLogger } from '../utils/Logger';
import { shallowObjectEqual } from '../hooks/useControllableState';
@@ -222,21 +222,31 @@ export const deepMergeState = (
* localDynamicState에 남은 stale 값이 deepMergeState에서 우선 적용되는 것을 방지합니다.
* 객체 키는 재귀적으로 처리하고, 배열/원시값은 해당 키를 삭제합니다.
*
* 제거된 것이 하나도 없으면 **원본 참조를 그대로 반환**합니다 (@since engine-v1.54.8).
* 호출부가 참조 비교로 "실제 제거 여부" 를 판정하기 때문입니다 — 내용이 같은데 사본을
* 새로 만들면 그 판정이 거짓 양성이 되어 불필요한 상태 갱신·캐시 무효화가 상시 발생합니다.
* 제거가 일어난 경우에도 변경이 없는 형제 가지는 참조를 보존합니다.
*
* @param target localDynamicState (제거 대상)
* @param keysToRemove setLocal이 업데이트한 키 구조
* @returns 해당 키가 제거된 새 객체
* @returns 해당 키가 제거된 새 객체 (제거가 없으면 target 원본 참조)
*/
export const removeMatchingLeafKeys = (
target: Record<string, any>,
keysToRemove: Record<string, any>
): Record<string, any> => {
const result: Record<string, any> = { ...target };
let result: Record<string, any> | null = null;
const ensureCopy = (): Record<string, any> => {
if (result === null) result = { ...target };
return result;
};
for (const key of Object.keys(keysToRemove)) {
if (!(key in result)) continue;
if (!(key in target)) continue;
const removeValue = keysToRemove[key];
const targetValue = result[key];
const targetValue = target[key];
if (
removeValue !== null &&
@@ -248,18 +258,21 @@ export const removeMatchingLeafKeys = (
) {
// 양쪽 모두 객체: 재귀적으로 처리
const cleaned = removeMatchingLeafKeys(targetValue, removeValue);
// 하위에서 제거된 것이 없으면(참조 동일) 이 가지는 손대지 않는다.
if (cleaned === targetValue) continue;
if (Object.keys(cleaned).length === 0) {
delete result[key];
delete ensureCopy()[key];
} else {
result[key] = cleaned;
ensureCopy()[key] = cleaned;
}
} else {
// 리프 값 또는 배열: 해당 키 삭제
delete result[key];
delete ensureCopy()[key];
}
}
return result;
return result ?? target;
};
/**
@@ -1249,6 +1262,12 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// API 데이터를 덮어쓰는 것을 방지 (플러그인 onMount setLocal 경합 해소)
const lastProcessedInitRef = useRef<any>(null);
// @since engine-v1.54.7: 이 인스턴스가 마지막으로 처리한 _localInit 추적 키
// 전역 추적(__g7LocalInitTracking)은 "payload 를 처음 관측했는가"만 판정하는데,
// 리셋 대상인 localDynamicState(저장소 A)는 인스턴스별이다. 전역 1회 소비만으로 끝내면
// 나머지 루트 렌더러의 저장소 A 에 stale leaf 가 영구 잔존한다 → resolveLocalInitAction 참조
const localInitHandledKeyRef = useRef<string | null>(null);
// 최신 _local 상태를 참조하는 ref (expandChildren 상태 동기화용)
// useCallback으로 캐싱된 componentContext에서도 최신 상태에 접근 가능하도록 함
const latestLocalStateRef = useRef<Record<string, any>>({});
@@ -1324,10 +1343,20 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
// 추적 키는 데이터 해시 + 타임스탬프 (컴포넌트 ID 제외)
// refetchOnMount 시 _forceLocalInit 타임스탬프가 변경되면 재적용됨
const trackingKey = `${currentHash}:${_forceLocalInit || 'no-force'}`;
const isNewData = tracking.hash !== trackingKey;
const shouldApply = isNewData;
// @since engine-v1.54.7: 적용 판정을 전역 해시 단독에서 (전역 해시 + 인스턴스 기록) 으로 확장
// - apply: 아무도 적용하지 않은 payload → 종전과 동일 (저장소 A + B + 캐시 무효화)
// - prune: 다른 인스턴스가 이미 적용 → 이 인스턴스의 저장소 A 에서 payload 키 공간만 제거
// - skip : 이 인스턴스가 이미 처리 → 무동작
const localInitAction = resolveLocalInitAction({
globalTrackedKey: tracking.hash,
instanceHandledKey: localInitHandledKeyRef.current,
trackingKey,
});
const shouldApply = localInitAction === 'apply';
if (shouldApply) {
localInitHandledKeyRef.current = trackingKey;
// 전역 추적 업데이트
tracking.hash = trackingKey;
tracking.timestamp = _forceLocalInit;
@@ -1399,6 +1428,40 @@ const DynamicRenderer: React.FC<DynamicRendererProps> = memo(
bindingEngine.invalidateCacheByKeys(['_local']);
logger.log('_localInit applied (data changed):', Object.keys(localInitData));
} else if (localInitAction === 'prune') {
// @since engine-v1.54.7
// 다른 인스턴스가 이 payload 를 이미 적용했다. 저장소 B(globalState._local)는 갱신되어
// 있으나, 이 인스턴스의 저장소 A(localDynamicState)에는 자동바인딩이 남긴 stale leaf 가
// 그대로다. 병합은 A 우선(`deepMergeState(dataContext._local, dynamicState)`)이므로
// 그대로 두면 신선한 B 를 stale A 가 덮는다 — 화면만 옛 값이 되는 조용한 결함.
//
// 값을 다시 쓰지 않고 payload 키 공간만 제거한다. 재적용을 택하면 늦게 마운트된
// 인스턴스가 소비된 과거 payload 를 B 에 되쓰면서 그 사이의 사용자 편집을 되돌린다
// (localInitSlot.ts 의 `consumed → 교체` 규칙이 막고 있는 회귀).
localInitHandledKeyRef.current = trackingKey;
const { _merge: _pruneMergeMode, ...pruneKeys } = localInitData as Record<string, any>;
// 상태 갱신은 updater 로 넘긴다 (@since engine-v1.54.8) — 같은 commit 에서
// useLayoutEffect 가 큐에 넣은 제거가 아직 반영되기 전일 수 있으므로,
// 커밋 시점 스냅샷을 직접 쓰면 그 제거를 되살릴 수 있다.
// removeMatchingLeafKeys 는 제거할 것이 없으면 원본 참조를 그대로 돌려주므로,
// no-op 일 때 React 가 바로 bail out 한다 (불필요한 리렌더 없음).
setLocalDynamicState(prev => removeMatchingLeafKeys(prev, pruneKeys));
// 캐시 무효화 여부는 커밋된 현재 값 기준으로 동기 판정한다 — updater 안에서
// 플래그를 세우면 StrictMode 이중 호출과 배치 지연 때문에 신뢰할 수 없다.
// 헬퍼가 no-op 시 원본 참조를 반환하므로 참조 비교가 곧 "실제 제거 여부" 다.
// (내용 비교나 얕은 비교로 판정하면 중첩 경로에서 사본이 새로 생겨 거짓 양성이 된다.)
const didPrune = removeMatchingLeafKeys(localDynamicState, pruneKeys) !== localDynamicState;
if (didPrune) {
// 병합 결과가 바뀌므로 _local 경로 캐시를 무효화한다. 단순 경로 바인딩
// (`{{_local.form.x}}` 등)은 캐시 대상이라, 무효화하지 않으면 제거 후에도
// 캐시된 옛 값이 반환되어 화면이 갱신되지 않는다 (troubleshooting-cache 사례 8·9).
bindingEngine.invalidateCacheByKeys(['_local']);
logger.log('_localInit pruned (applied by another renderer):', Object.keys(pruneKeys));
}
}
// engine-v1.41.0: _localInit 처리 완료 후 ref 갱신
@@ -15,7 +15,9 @@ import {
markLocalInitConsumed,
mergeLocalInitSlot,
resetLocalInitTracking,
resolveLocalInitAction,
} from '../localInitSlot';
import { deepMergeState, removeMatchingLeafKeys } from '../DynamicRenderer';
import { Logger } from '../../utils/Logger';
// AuthManager mock
@@ -777,3 +779,253 @@ describe('트러블슈팅 회귀 테스트 - 데이터소스 업데이트 및
});
});
});
describe('[사례 9] 탭 왕복 후 먼저 편집한 입력칸만 옛 값이 남음 (engine-v1.54.7)', () => {
/**
* describe 번호는 이 파일 내 연번이다(앞의 사례 5·6·7·8 에 이어짐) — 트러블슈팅 문서의
* 섹션별 재시작 번호와는 일치하지 않는다.
*
* 증상: 폼 데이터소스가 `initLocal` + `refetchOnMount: true` 이고, 탭 전환이 URL 을 바꿔
* remount + refetch 를 유발하는 화면에서, 되돌아오기 전 입력칸을 2개 이상 편집하면
* **먼저 편집한 칸에 사용자가 친 값이 남고** 마지막에 편집한 칸만 서버값으로 복귀한다.
* 저장값은 서버값이므로 화면 표시와 실제 값이 어긋난다(새로고침하면 서버값으로 돌아옴).
*
* "먼저 편집" 은 필요조건이지 충분조건이 아니다 — 잔존이 화면까지 드러나려면 그 입력칸을
* 소유한 렌더러 인스턴스가 저장소 A 리셋을 건너뛴 쪽이어야 한다(통제 실험은 문서 참조).
* 재현에는 **SPA 라우팅(탭 클릭)** 이 필요하다. 주소창 이동·새로고침은 렌더러를 전부 새로
* 마운트하고 전역 추적도 초기화하므로 결함이 드러나지 않는다.
*
* 원인(둘의 합성):
* ① 저장소 A 오염 — 자동바인딩 `performStateUpdate` 는 키입력마다 병합된 `_local` **전체
* 스냅샷**을 저장소 A 에 쓰고, `useLayoutEffect` 의 `removeMatchingLeafKeys` 는
* `__g7SetLocalOverrideKeys` 에 남은 **마지막 leaf 만** 지운다. 이 전역 플래그는
* `queueMicrotask` 로 클리어되므로 다음 필드를 칠 때 직전 필드 키는 이미 사라져 있다.
* → A 에 "직전까지 타이핑한 필드들"의 사본이 잔존한다.
* ② `_localInit` 리셋의 전역 1회 소비 — 적용 여부를 전역 해시(`__g7LocalInitTracking`)로만
* 판정하는데, 실제 리셋 대상인 `localDynamicState` 는 **인스턴스별**이다. 루트급 렌더러가
* 복수(global_toast, page_transition, admin_layout_root …)이므로 먼저 effect 가 도는
* 인스턴스가 토큰을 소비하면 나머지 인스턴스의 A 는 영원히 리셋되지 않는다.
* → 병합(`deepMergeState(B, A)`)은 A 우선이므로 신선한 B 위에 stale A 가 덮인다.
*
* 이는 새 유형이 아니라 `troubleshooting-state-advanced.md` 사례 13(engine-v1.18.3)이 세운
* "동일 commit 내 복수 root 의 실행 순서에 의존 금지" 규칙의 **미적용 구간**이다.
* `__g7SetLocalOverrideKeys` 는 그때 규칙을 적용받았으나 `__g7LocalInitTracking` 은 남았다.
*
* 해결: 전역 해시 때문에 적용을 건너뛴 인스턴스는 자기 저장소 A 에서 payload 키 공간을
* **제거만** 한다(값을 다시 쓰지 않는다). 제거는 stale 값을 되살릴 수 없고, 제거된
* 자리에는 이미 갱신된 저장소 B 가 그대로 비쳐 보인다. 재적용을 택하면
* `localInitSlot.ts` 가 금지한 "소비된 payload 재적용 → 폼 편집 되돌림" 회귀가 난다.
*
* @see resources/js/core/template-engine/localInitSlot.ts - resolveLocalInitAction
* @see resources/js/core/template-engine/DynamicRenderer.tsx - _localInit useEffect
*/
const TRACKING_KEY = '{"form":{"a":1}}:1700000000000';
describe('[판정] resolveLocalInitAction', () => {
it('전역 추적에 없는 payload 는 적용한다 (apply)', () => {
expect(resolveLocalInitAction({
globalTrackedKey: '',
instanceHandledKey: null,
trackingKey: TRACKING_KEY,
})).toBe('apply');
});
it('다른 인스턴스가 이미 적용한 payload 는 이 인스턴스에서 제거한다 (prune)', () => {
expect(resolveLocalInitAction({
globalTrackedKey: TRACKING_KEY,
instanceHandledKey: null,
trackingKey: TRACKING_KEY,
})).toBe('prune');
});
it('같은 인스턴스가 이미 처리한 payload 는 아무것도 하지 않는다 (skip)', () => {
expect(resolveLocalInitAction({
globalTrackedKey: TRACKING_KEY,
instanceHandledKey: TRACKING_KEY,
trackingKey: TRACKING_KEY,
})).toBe('skip');
});
it('적용한 인스턴스가 재발화해도 재적용하지 않는다 (apply 후 skip)', () => {
// 적용 인스턴스는 전역·인스턴스 양쪽에 같은 키를 기록한다
expect(resolveLocalInitAction({
globalTrackedKey: TRACKING_KEY,
instanceHandledKey: TRACKING_KEY,
trackingKey: TRACKING_KEY,
})).toBe('skip');
});
it('payload 가 바뀌면 인스턴스 기록과 무관하게 다시 적용한다', () => {
expect(resolveLocalInitAction({
globalTrackedKey: TRACKING_KEY,
instanceHandledKey: TRACKING_KEY,
trackingKey: '{"form":{"a":2}}:1700000002000',
})).toBe('apply');
});
});
describe('[재현] 탭 왕복 후 마지막 편집 칸만 리셋되는 순서 의존성', () => {
/**
* 자동바인딩 2필드 순차 입력의 저장소 A 상태를 그대로 합성한다.
* `performStateUpdate` 가 매번 전체 스냅샷을 A 에 쓰고, 마지막 leaf 만 정리된 상태.
*/
const buildStoreAAfterTypingTwoFields = () => {
// 서버 원본
const server = { order_settings: { auto_cancel_days: 3, cart_expiry_days: 30 } };
// ① auto_cancel_days=7 타이핑 → A 에 전체 스냅샷
let storeA: Record<string, any> = deepMergeState(
{ loadingActions: {} },
{ form: { ...server, order_settings: { ...server.order_settings, auto_cancel_days: 7 } } }
);
// setLocal 정리: 이 필드 leaf 제거 → 이후 queueMicrotask 로 플래그 클리어
storeA = removeMatchingLeafKeys(storeA, { form: { order_settings: { auto_cancel_days: 7 } } });
// ② cart_expiry_days=15 타이핑 → A 에 전체 스냅샷 (B 의 7 을 base 로 흡수)
storeA = deepMergeState(storeA, {
form: { order_settings: { auto_cancel_days: 7, cart_expiry_days: 15 } },
});
// 정리 대상은 이번 leaf 뿐 — 직전 필드 키는 플래그에서 이미 사라졌다
storeA = removeMatchingLeafKeys(storeA, { form: { order_settings: { cart_expiry_days: 15 } } });
return storeA;
};
it('저장소 A 에 직전 필드 사본이 남고 마지막 필드만 정리된다 (결함 전제)', () => {
const storeA = buildStoreAAfterTypingTwoFields();
expect(storeA.form.order_settings.auto_cancel_days).toBe(7); // 잔존
expect(storeA.form.order_settings.cart_expiry_days).toBeUndefined(); // 정리됨
});
it('제거 없이 병합하면 신선한 저장소 B 가 stale 저장소 A 에 덮인다 (수정 전 동작)', () => {
const storeA = buildStoreAAfterTypingTwoFields();
// refetch 로 갱신된 저장소 B
const storeB = { form: { order_settings: { auto_cancel_days: 3, cart_expiry_days: 30 } } };
const merged = deepMergeState(storeB, storeA);
expect(merged.form.order_settings.auto_cancel_days).toBe(7); // ← 화면에 보이는 stale
expect(merged.form.order_settings.cart_expiry_days).toBe(30); // ← 마지막 필드만 정상
});
it('건너뛴 인스턴스가 payload 키 공간을 제거하면 두 필드 모두 서버값이 된다 (수정 후)', () => {
const storeA = buildStoreAAfterTypingTwoFields();
const payload = { form: { order_settings: { auto_cancel_days: 3, cart_expiry_days: 30 } } };
const storeB = { form: { order_settings: { auto_cancel_days: 3, cart_expiry_days: 30 } } };
const pruned = removeMatchingLeafKeys(storeA, payload);
const merged = deepMergeState(storeB, pruned);
expect(merged.form.order_settings.auto_cancel_days).toBe(3);
expect(merged.form.order_settings.cart_expiry_days).toBe(30);
});
});
describe('[안전성] 제거는 값을 도입하지 않는다', () => {
it('payload 에 없는 키는 저장소 A 에 그대로 남는다', () => {
const storeA = {
loadingActions: { save: true },
ui: { accordionOpen: true },
form: { name: '사용자입력' },
};
const payload = { form: { name: '서버값' } };
const pruned = removeMatchingLeafKeys(storeA, payload);
expect(pruned.loadingActions).toEqual({ save: true });
expect(pruned.ui).toEqual({ accordionOpen: true });
expect(pruned.form).toBeUndefined();
});
it('저장소 A 가 비어 있으면 제거는 no-op 이다 (늦게 마운트된 인스턴스)', () => {
const storeA = { loadingActions: {} };
const payload = { form: { name: '서버값' } };
expect(removeMatchingLeafKeys(storeA, payload)).toEqual({ loadingActions: {} });
});
it('제거는 저장소 B 를 건드리지 않는다 — 소비된 payload 재적용 회귀가 구조적으로 불가', () => {
// 늦게 마운트된 인스턴스가 과거 payload 를 들고 있어도, 제거만 하므로
// 그 사이 사용자가 편집한 저장소 B 의 값이 되돌아가지 않는다.
const storeB = { form: { name: '편집중인값' } };
const stalePayload = { form: { name: '과거서버값' } };
const storeA = { loadingActions: {} };
const pruned = removeMatchingLeafKeys(storeA, stalePayload);
const merged = deepMergeState(storeB, pruned);
expect(merged.form.name).toBe('편집중인값');
});
});
/**
* prune 분기는 "실제로 제거된 것이 있을 때만" 상태 갱신 + 캐시 무효화를 해야 한다
* (no-op 인데 무효화하면 불필요한 리렌더와 캐시 폐기가 상시 발생).
*
* 판정은 `localDynamicState !== removeMatchingLeafKeys(...)` 참조 비교로 하므로,
* 헬퍼가 **제거가 없을 때 원본 참조를 그대로 반환**해야 판정이 성립한다.
* 중첩 경로에서 사본을 새로 만들면 내용이 같아도 참조가 달라져 거짓 양성이 된다.
*
* @effects prune_is_noop_when_nothing_to_remove, prune_never_reintroduces_values
*/
describe('[no-op] 제거할 것이 없으면 참조가 보존된다 (거짓 양성 차단)', () => {
it('최상위에 겹치는 키가 없으면 원본 참조를 그대로 반환한다', () => {
const storeA = { loadingActions: { save: true } };
const payload = { form: { name: '서버값' } };
expect(removeMatchingLeafKeys(storeA, payload)).toBe(storeA);
});
it('중첩 경로가 겹쳐도 실제 제거가 없으면 원본 참조를 그대로 반환한다', () => {
// 저장소 A 에는 theme 만, payload 에는 auto_cancel_days 만 → 제거 대상 0
const storeA = { form: { theme: 'dark' } };
const payload = { form: { auto_cancel_days: 7 } };
const pruned = removeMatchingLeafKeys(storeA, payload);
expect(pruned).toBe(storeA);
expect(pruned.form).toBe(storeA.form);
});
it('저장소 A 가 비어 있으면 원본 참조를 그대로 반환한다 (늦게 마운트된 인스턴스)', () => {
const storeA = {};
const payload = { form: { order_settings: { auto_cancel_days: 7 } } };
expect(removeMatchingLeafKeys(storeA, payload)).toBe(storeA);
});
it('실제로 제거되면 새 참조를 반환한다 (판정이 항상 false 가 되지 않음)', () => {
const storeA = { form: { theme: 'dark', auto_cancel_days: 7 } };
const payload = { form: { auto_cancel_days: 7 } };
const pruned = removeMatchingLeafKeys(storeA, payload);
expect(pruned).not.toBe(storeA);
expect(pruned.form).toEqual({ theme: 'dark' });
});
it('깊은 중첩에서 한 리프만 제거돼도 새 참조를 반환한다', () => {
const storeA = { form: { order_settings: { auto_cancel_days: 7, cart_expiry_days: 15 } } };
const payload = { form: { order_settings: { auto_cancel_days: 7 } } };
const pruned = removeMatchingLeafKeys(storeA, payload);
expect(pruned).not.toBe(storeA);
expect(pruned.form.order_settings).toEqual({ cart_expiry_days: 15 });
});
it('제거되지 않은 형제 가지는 참조까지 보존된다 (불필요한 하위 리렌더 차단)', () => {
const storeA = {
ui: { accordionOpen: true },
form: { theme: 'dark', auto_cancel_days: 7 },
};
const payload = { form: { auto_cancel_days: 7 } };
const pruned = removeMatchingLeafKeys(storeA, payload);
expect(pruned).not.toBe(storeA);
expect(pruned.ui).toBe(storeA.ui);
});
});
});
@@ -90,6 +90,51 @@ export function isLocalInitConsumed(slot: unknown): boolean {
return getLocalInitTracking().consumed === slot;
}
/**
* `_localInit` payload 에 대해 한 렌더러 인스턴스가 취할 동작.
*
* - `apply`: 이 payload 를 아직 아무도 적용하지 않았다 → 저장소 A·B 를 모두 갱신
* - `prune`: 다른 인스턴스가 이미 적용했다 → **이 인스턴스의 저장소 A 에서 payload 키 공간만 제거**
* - `skip`: 이 인스턴스가 이미 처리했다 → 아무것도 하지 않음
*
* @since engine-v1.54.7
*/
export type LocalInitAction = 'apply' | 'prune' | 'skip';
/**
* `_localInit` payload 에 대해 이 렌더러 인스턴스가 취할 동작을 판정합니다.
*
* 전역 해시(`__g7LocalInitTracking.hash`)는 "이 payload 를 처음 관측했는가"만 판정한다.
* 그러나 리셋 대상인 `localDynamicState`(저장소 A)는 **인스턴스별**이므로, 전역 1회 소비만으로
* 끝내면 나머지 루트 렌더러의 저장소 A 에 stale leaf 가 영구 잔존한다. 그 잔존값은
* `deepMergeState(dataContext._local, dynamicState)` 에서 A 우선이라 신선한 저장소 B 를 덮는다.
*
* 건너뛴 인스턴스가 payload 를 **다시 적용**해서는 안 된다 — 늦게 마운트된 인스턴스가 이미
* 소비된 과거 payload 를 저장소 B 에 되쓰면서 그 사이의 사용자 편집을 되돌린다
* (`mergeLocalInitSlot` 의 `consumed → 교체` 규칙이 막고 있는 바로 그 회귀). 그래서 `prune` 은
* 값을 도입하지 않고 제거만 한다. 제거는 stale 값을 되살릴 수 없고, 제거된 자리에는 이미
* 갱신된 저장소 B 가 그대로 비쳐 보인다.
*
* 배경: `troubleshooting-state-advanced.md` 사례 13(engine-v1.18.3)이 세운
* "동일 commit 내 복수 root 의 실행 순서에 의존 금지" 규칙의 미적용 구간이었다.
*
* @param params.globalTrackedKey 전역 레지스트리에 기록된 추적 키
* @param params.instanceHandledKey 이 인스턴스가 마지막으로 처리한 추적 키 (미처리 시 null)
* @param params.trackingKey 이번 payload 의 추적 키 (`해시:_forceLocalInit`)
* @returns 이 인스턴스가 취할 동작
*/
export function resolveLocalInitAction(params: {
globalTrackedKey: string;
instanceHandledKey: string | null;
trackingKey: string;
}): LocalInitAction {
if (params.globalTrackedKey !== params.trackingKey) {
return 'apply';
}
return params.instanceHandledKey === params.trackingKey ? 'skip' : 'prune';
}
/**
* `_forceLocalInit` 타임스탬프를 병합합니다.
*
@@ -31,7 +31,7 @@
- 주문서의 "배송지 관리"에서 해외 배송지의 주소가 표시되지 않고 빈 괄호만 보이던 문제를 수정했습니다. 국내 주소 형식으로만 표시하고 있어 해외 주소가 비어 보였으며, 이제 마이페이지 배송지 목록과 동일하게 국가에 맞는 형식으로 표시됩니다.
- 저장이나 삭제가 실패했을 때 아무 안내 없이 조용히 넘어가던 화면들을 수정했습니다. 이제 실패하면 그 이유가 화면에 표시되므로, 반영되지 않은 변경을 성공한 것으로 오해하지 않습니다.
- 오류 안내가 뜨기는 하지만 내용이 비어 있던 문제를 수정했습니다. 서버가 알려 준 실패 사유가 그대로 표시됩니다.
- 주문서의 무통장입금·가상계좌 입금 기한 안내가 관리자가 설정한 미입금 자동취소 기한과 다르게 표시되던 문제를 수정했습니다. 설정과 무관하게 각각 7일·3일로 고정 안내되어, 자동취소 기한을 3일로 둔 사이트에서 무통장입금 구매자는 "7일 이내"라는 안내를 받고도 3일 뒤 주문이 취소됐습니다. 이제 두 결제수단 모두 설정한 기한을 그대로 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 주문서의 무통장입금·가상계좌 입금 기한 안내가 관리자가 설정한 미입금 자동취소 기한과 다르게 표시되던 문제를 수정했습니다. 설정과 무관하게 각각 7일·3일로 고정 안내되어, 자동취소 기한을 3일로 둔 사이트에서 무통장입금 구매자는 "7일 이내"라는 안내를 받고도 3일 뒤 주문이 취소됐습니다. 이제 두 결제수단 모두 설정한 기한을 그대로 안내합니다. 기한이 비워진 채 저장돼 있거나 사용할 수 없는 값이 남아 있는 사이트에서도 실제 적용되는 기본 기한(3일)을 안내합니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
#### 화면 표시
@@ -18,6 +18,15 @@
* 1. 두 안내 모두 `order_settings.auto_cancel_days` 를 참조한다.
* 2. 서버에 없는 `*_due_days` 키를 참조하지 않는다 (되살아나면 폴백으로 조용히 회귀).
* 3. 폴백 값은 서버 기본치(3)와 같다 — 설정 응답이 늦거나 실패해도 안내가 어긋나지 않도록.
*
* 후속 결함(브라우저 실측 2026-08-01): 폴백이 `?? 3`(nullish) 이라 `null`/`undefined` 만
* 막고 **빈 문자열은 통과**했다. 설정에 `auto_cancel_days: ""` 가 남아 있으면
* `days=` 가 빈 값으로 넘어가 치환이 스킵되고 주문서에 `입금 기한: {{days}}일 이내` 가
* 그대로 노출된다(실측: `/api/modules/sirsoft-ecommerce/settings/payment` 가 `""` 를 그대로 응답).
* 서버(`OrderProcessingService::resolveAutoCancelDays`)는 같은 값에서 3일로 폴백하므로,
* 화면만 깨지고 실제 기한은 정상이라 조용한 불일치가 된다.
*
* 따라서 폴백 판정은 서버와 같은 규칙(숫자로 해석되는 양수만 사용, 그 외 기본치 3)이어야 한다.
*/
import { describe, it, expect } from 'vitest';
@@ -26,18 +35,47 @@ import checkoutPayment from '../../layouts/partials/shop/_checkout_payment.json'
const raw = JSON.stringify(checkoutPayment);
/** 서버 SSoT 표현식 — 결제수단 무관 단일 기준 */
const SSOT_EXPRESSION = 'paymentSettings.data?.order_settings?.auto_cancel_days ?? 3';
/** 서버 SSoT 키 — 결제수단 무관 단일 기준 */
const SSOT_KEY = 'order_settings?.auto_cancel_days';
/** 서버 기본치 (`OrderProcessingService::AUTO_CANCEL_DAYS_DEFAULT`) */
const SERVER_DEFAULT = 3;
/**
* 안내 문구의 `days=` 바인딩 표현식을 레이아웃에서 추출합니다.
*
* @param translationKey 다국어 키 (vbank_due_notice / dbank_due_notice)
* @returns `{{ }}` 안쪽 표현식 본문
*/
function extractDaysExpression(translationKey: string): string {
const entry = raw.match(new RegExp(`\\$t:shop\\.checkout\\.${translationKey}\\|days=\\{\\{(.+?)\\}\\}`));
expect(entry, `${translationKey} 안내의 days 바인딩을 찾지 못했습니다`).not.toBeNull();
return entry![1];
}
/**
* 추출한 표현식을 실제로 평가합니다 — 문자열 형태가 아닌 **동작**을 잠급니다.
*
* @param expression 레이아웃에서 추출한 표현식
* @param autoCancelDays 설정 응답이 담고 있는 원본 값
* @returns 안내 문구에 치환될 값
*/
function evaluateDays(expression: string, autoCancelDays: unknown): unknown {
const fn = new Function('paymentSettings', `return (${expression});`);
return fn({ data: { order_settings: { auto_cancel_days: autoCancelDays } } });
}
const NOTICES: Array<[string, string]> = [
['가상계좌', 'vbank_due_notice'],
['무통장입금', 'dbank_due_notice'],
];
describe('주문서 입금 기한 안내 — 서버 SSoT 바인딩 (#493 E1)', () => {
it.each([
['가상계좌', 'vbank_due_notice'],
['무통장입금', 'dbank_due_notice'],
])('%s 안내가 auto_cancel_days 를 참조한다', (_label, translationKey) => {
const entry = raw.match(new RegExp(`\\$t:shop\\.checkout\\.${translationKey}\\|days=[^"]*`))?.[0];
expect(entry, `${translationKey} 안내 문구를 찾지 못했습니다`).toBeDefined();
expect(entry).toContain(SSOT_EXPRESSION);
it.each(NOTICES)('%s 안내가 auto_cancel_days 를 참조한다', (_label, translationKey) => {
expect(extractDaysExpression(translationKey)).toContain(SSOT_KEY);
});
it.each([
@@ -50,3 +88,23 @@ describe('주문서 입금 기한 안내 — 서버 SSoT 바인딩 (#493 E1)', (
).toBe(false);
});
});
describe('주문서 입금 기한 안내 — 오염된 설정값 폴백 (서버 규칙 일치)', () => {
/** [라벨, 저장된 값, 기대 표시값] — 서버 resolveAutoCancelDays 와 동일 규칙 */
const CASES: Array<[string, unknown, number]> = [
['빈 문자열 (검증 강화 이전 저장분)', '', SERVER_DEFAULT],
['null', null, SERVER_DEFAULT],
['undefined (키 부재)', undefined, SERVER_DEFAULT],
['0 (즉시 만료 — 실질 사용 불가)', 0, SERVER_DEFAULT],
['음수', -1, SERVER_DEFAULT],
['비숫자 문자열', 'abc', SERVER_DEFAULT],
['숫자 문자열', '5', 5],
['정수', 5, 5],
];
describe.each(NOTICES)('%s 안내', (_label, translationKey) => {
it.each(CASES)('%s → %s 일로 안내한다', (_caseLabel, stored, expected) => {
expect(evaluateDays(extractDaysExpression(translationKey), stored)).toBe(expected);
});
});
});
@@ -197,10 +197,11 @@
"text": "$t:shop.checkout.vbank_info"
},
{
"comment": "입금 기한 — 서버(OrderProcessingService::resolveAutoCancelDays)와 같은 규칙으로 폴백한다. ?? 는 null/undefined 만 막아 빈 문자열('')이 통과하면 days 가 비어 치환이 스킵되고 '{{days}}' 원문이 그대로 노출된다(실측 2026-08-01). 0/음수/비숫자도 서버가 기본치 3 으로 되돌리므로 화면도 동일 판정을 쓴다.",
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-blue-600 dark:text-blue-400" },
"text": "$t:shop.checkout.vbank_due_notice|days={{paymentSettings.data?.order_settings?.auto_cancel_days ?? 3}}"
"text": "$t:shop.checkout.vbank_due_notice|days={{Number(paymentSettings.data?.order_settings?.auto_cancel_days) > 0 ? Number(paymentSettings.data?.order_settings?.auto_cancel_days) : 3}}"
},
{
"comment": "입금자명 입력",
@@ -422,11 +423,11 @@
]
},
{
"comment": "입금 기한 안내",
"comment": "입금 기한 안내 — 가상계좌 안내와 동일 규칙(서버 resolveAutoCancelDays 일치). 빈 문자열/0/음수/비숫자는 기본치 3 으로 되돌린다.",
"type": "basic",
"name": "P",
"props": { "className": "text-xs text-amber-600 dark:text-amber-400 mt-3" },
"text": "$t:shop.checkout.dbank_due_notice|days={{paymentSettings.data?.order_settings?.auto_cancel_days ?? 3}}"
"text": "$t:shop.checkout.dbank_due_notice|days={{Number(paymentSettings.data?.order_settings?.auto_cancel_days) > 0 ? Number(paymentSettings.data?.order_settings?.auto_cancel_days) : 3}}"
}
]
}
@@ -10,43 +10,198 @@
* "입금 기한: 7일 이내" 로 안내했다. 안내를 믿은 구매자는 3일 뒤 주문이 자동취소된 뒤에야
* 알게 된다 — 화면만 봐서는 어긋난 것을 알 방법이 없다.
*
* 후속 결함(브라우저 실측 2026-08-01): 폴백이 `?? 3`(nullish) 이라 빈 문자열이 통과해
* `days=` 가 비고 치환이 스킵되어 주문서에 `입금 기한: {{days}}일 이내` 가 그대로 노출됐다.
* 서버는 같은 값에서 3일로 폴백하므로 화면만 깨지는 조용한 불일치였다. 그래서 이 spec 은
* 숫자 일치뿐 아니라 **미치환 플레이스홀더 부재**도 함께 고정한다.
*
* 단위/정적:
* - templates/_bundled/sirsoft-basic/__tests__/layouts/checkout-deposit-due-days.test.ts
* 가 레이아웃 JSON 의 표현식을 고정하고 고아 키 재등장을 차단
* 가 레이아웃 표현식을 실제로 평가해 오염값(빈 문자열/0/음수/비숫자) 폴백을 잠근다.
* 이 spec 은 브라우저 수준 — 렌더된 안내 숫자가 설정 응답의 값과 같은지를 담당한다.
*
* @scenario checkout-deposit-due
* @axes method=dbank
* @effects due_notice_matches_auto_cancel_days
*
* e2e:allow 주문서 진입은 장바구니에 담긴 항목이 있어야 해 로그인 계정·시드 상품에 의존한다.
* 현재는 설정 응답값과 레이아웃 표현식의 일치까지를 자동 검증하고, 렌더 확인은
* 장바구니 시드 픽스처 도입 후 활성화한다. 브라우저 실렌더는 2026-07-29 수동
* 실측으로 확인됨("입금 기한: 3일 이내", auto_cancel_days=3).
* @axes method=dbank,vbank
* @effects due_notice_matches_auto_cancel_days, due_notice_has_no_unsubstituted_placeholder
*/
import { test, expect } from '@playwright/test';
import { test, expect, type Page } from '@playwright/test';
test('#81 - 설정 응답이 입금 기한 SSoT(auto_cancel_days)를 노출한다', async ({ page }) => {
import { issueToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
/** 서버 기본치 (`OrderProcessingService::AUTO_CANCEL_DAYS_DEFAULT`) */
const SERVER_DEFAULT_DAYS = 3;
/**
* 설정 응답의 원본 값을 서버(`resolveAutoCancelDays`)와 같은 규칙으로 해석합니다.
*
* @param raw 설정 응답이 담고 있는 원본 값
* @returns 안내에 표시되어야 할 일수
*/
function resolveExpectedDays(raw: unknown): number {
return Number(raw) > 0 ? Number(raw) : SERVER_DEFAULT_DAYS;
}
/**
* 판매 중인 상품 1건을 장바구니에 담습니다 (주문서 진입 조건 확보).
*
* 상품 상세 UI(옵션 드롭다운 → 바로 구매)를 거치지 않는다. 옵션 구성은 상품마다 다르고
* 하위 옵션이 비동기로 열려, 그 경로를 통과하는 것 자체가 이 spec 의 검증 대상과 무관하게
* 불안정하다(실측: 동일 코드에서 통과/타임아웃이 갈렸다). 검증 대상인 주문서 입금 기한 안내는
* 담은 뒤 전부 브라우저 실렌더로 측정한다.
*
* @param page 대상 페이지 (인증 토큰이 주입된 상태여야 한다)
*/
async function seedCart(page: Page): Promise<void> {
const result = await page.evaluate(async () => {
const token = localStorage.getItem('auth_token');
const headers = { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` };
const listResponse = await fetch('/api/modules/sirsoft-ecommerce/products?per_page=1', { headers });
const productCode = (await listResponse.json())?.data?.data?.[0]?.product_code ?? null;
if (!productCode) {
return { status: 0, reason: '판매 중인 상품이 없습니다' };
}
const detail = await (await fetch(`/api/modules/sirsoft-ecommerce/products/${productCode}`, { headers })).json();
const product = detail?.data ?? {};
const addResponse = await fetch('/api/modules/sirsoft-ecommerce/cart', {
method: 'POST',
headers,
body: JSON.stringify({
product_id: product.id,
items: [{ product_option_id: product.options?.[0]?.id ?? null, quantity: 1 }],
}),
});
return { status: addResponse.status, reason: addResponse.ok ? null : JSON.stringify(await addResponse.json()) };
});
expect(result.status, `장바구니 담기에 실패했습니다: ${result.reason}`).toBe(201);
}
/**
* 주문서에 진입합니다 (상품 상세 → 옵션 선택 → 바로 구매).
*
* 대상 상품은 목록 API 로 고른다 — 카드 마크업/정렬은 이 spec 의 검증 대상이 아니고,
* 시드가 바뀌면 조용히 깨지는 하드코딩 상품코드를 피하기 위함이다. 검증 대상인
* 입금 기한 안내는 그 뒤 전부 브라우저 실렌더로 측정한다.
*
* @param page 대상 페이지
*/
/**
* GDPR 쿠키 동의 배너를 닫습니다.
*
* 신규 브라우저 컨텍스트에는 동의 이력이 없어 배너가 하단 고정으로 뜬다. 배너가 옵션/구매
* 영역을 덮으면 클릭이 재시도만 반복하다 타임아웃으로 죽어, 판정기가 검증 대상에 닿기도 전에
* 자기 문제로 실패한다(실측). 배너가 없으면 조용히 지나간다.
*
* @param page 대상 페이지
*/
async function dismissCookieBanner(page: Page): Promise<void> {
const accept = page.getByRole('button', { name: '모두 동의' }).first();
try {
await accept.waitFor({ state: 'visible', timeout: 3000 });
} catch {
return;
}
await accept.click();
await accept.waitFor({ state: 'hidden', timeout: 5000 });
}
/**
* 주문서에 진입합니다 (장바구니 시드 → 장바구니 화면 → 주문하기).
*
* `/shop/checkout` 으로 바로 이동하면 "주문 정보가 없습니다" 모달이 뜬다 — 주문서는 장바구니에
* 담긴 것만으로 열리지 않고, 장바구니에서 주문 대상을 확정해야 한다(실측).
*
* @param page 대상 페이지
*/
async function gotoCheckout(page: Page): Promise<void> {
await page.goto('/shop/products');
await dismissCookieBanner(page);
await seedCart(page);
await page.goto('/shop/cart');
await dismissCookieBanner(page);
await page.getByRole('button', { name: '주문하기' }).first().click();
await page.waitForURL(/\/shop\/checkout/);
}
test.describe('#81 주문서 입금 기한 안내', () => {
test('설정 응답이 입금 기한 SSoT(auto_cancel_days)를 노출한다', async ({ page }) => {
await page.goto('/shop/products');
const orderSettings = await page.evaluate(async () => {
const response = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
headers: { Accept: 'application/json' },
});
const body = await response.json();
const response = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
headers: { Accept: 'application/json' },
});
const body = await response.json();
return (body?.data?.order_settings ?? {}) as Record<string, unknown>;
return (body?.data?.order_settings ?? {}) as Record<string, unknown>;
});
// 화면이 참조하는 키가 응답에 있어야 한다. 없으면 폴백으로 떨어져 설정과 어긋난다.
expect(
Object.keys(orderSettings),
'입금 기한 SSoT 가 결제 설정 응답에 없습니다 — 주문서 안내가 하드코딩 폴백으로 떨어집니다.'
Object.keys(orderSettings),
'입금 기한 SSoT 가 결제 설정 응답에 없습니다 — 주문서 안내가 하드코딩 폴백으로 떨어집니다.'
).toContain('auto_cancel_days');
expect(Number(orderSettings.auto_cancel_days)).toBeGreaterThan(0);
// 폐기된 키가 되살아나면 화면이 다시 그 값을 따라가므로 부재를 함께 고정한다.
expect(Object.keys(orderSettings)).not.toContain('dbank_due_days');
expect(Object.keys(orderSettings)).not.toContain('vbank_due_days');
});
test('렌더된 안내가 설정값과 일치하고 미치환 플레이스홀더가 없다', async ({ page }) => {
await authenticatePage(page, issueToken());
await gotoCheckout(page);
const rawSetting = await page.evaluate(async () => {
const response = await fetch('/api/modules/sirsoft-ecommerce/settings/payment', {
headers: { Accept: 'application/json' },
});
const body = await response.json();
return (body?.data?.order_settings ?? {}).auto_cancel_days ?? null;
});
const expectedDays = resolveExpectedDays(rawSetting);
// 결제수단은 설정 응답 도착 후에 그려진다 — 렌더 전에 조회하면 전부 "없음"으로 읽혀
// 아무것도 측정하지 않은 채 통과한다. 섹션이 뜬 것을 먼저 확정한다.
await expect(page.getByText('결제 수단').first()).toBeVisible();
// 주문 금액 계산 중에는 전면 오버레이가 떠 결제수단 클릭을 가로챈다(실측: 30초 재시도 후 사망).
// 합계가 그려지면 계산이 끝난 것이므로 그때까지 기다린다.
await expect(page.getByText('총 결제금액').first()).toBeVisible({ timeout: 15000 });
// 활성 결제수단만 화면에 뜬다 — 사이트 설정에 따라 한쪽만 있을 수 있으므로 존재하는 것만 검사한다.
let measured = 0;
for (const label of ['무통장입금', '가상계좌']) {
const method = page.getByRole('button', { name: new RegExp(label) }).first();
try {
await method.waitFor({ state: 'visible', timeout: 3000 });
} catch {
continue;
}
await method.click();
const notice = page.getByText(/입금 기한/).first();
await expect(notice).toBeVisible();
const text = (await notice.innerText()).trim();
// 치환 실패는 원문(`{{days}}`)이 그대로 남는 형태로 드러난다 — 숫자 비교보다 먼저 잠근다.
expect(text, `${label} 안내에 미치환 플레이스홀더가 남아 있습니다: ${text}`).not.toContain('{{');
expect(text, `${label} 안내가 설정값(${expectedDays}일)과 다릅니다: ${text}`).toContain(String(expectedDays));
measured += 1;
}
expect(measured, '활성 결제수단이 하나도 없어 안내를 측정하지 못했습니다').toBeGreaterThan(0);
});
});
+27 -1
View File
@@ -45,8 +45,34 @@ function resolveCoreRoot(): string {
* @returns 발급된 plainText Sanctum 토큰 (커맨드 stdout 의 마지막 비어있지 않은 줄)
*/
export function issueToken(...permissions: string[]): string {
return runIssueToken(permissions, false);
}
/**
* **지정한 권한만** 가진 Sanctum 토큰을 발급한다 (admin 역할 미부여).
*
* `issueToken` 은 커맨드 기본 동작대로 사이트의 `admin` 역할을 함께 부여한다. admin 역할은
* 전체 권한을 보유하므로(실측 263건), 권한을 좁혀 넘겨도 화면은 항상 최대 권한으로 렌더된다.
* 읽기 전용 분기·권한 미보유 분기처럼 **권한 경계 자체를 검증**할 때만 이 함수를 쓴다.
*
* @param permissions 권한 식별자 가변 인자 (빈 배열이면 권한 0건 계정)
* @returns 발급된 plainText Sanctum 토큰
*/
export function issueScopedToken(...permissions: string[]): string {
return runIssueToken(permissions, true);
}
/**
* `playwright:issue-token` 을 실행해 토큰 문자열을 얻는다.
*
* @param permissions 권한 식별자 목록
* @param noAdminRole admin 역할 미부여 여부
* @returns 발급된 plainText Sanctum 토큰 (커맨드 stdout 의 마지막 비어있지 않은 줄)
*/
function runIssueToken(permissions: string[], noAdminRole: boolean): string {
const args = permissions.map((p) => `--permissions=${p}`).join(' ');
const command = `php artisan playwright:issue-token ${args}`.trim();
const scopeFlag = noAdminRole ? ' --no-admin-role' : '';
const command = `php artisan playwright:issue-token ${args}${scopeFlag}`.trim();
// 다수 워커가 동시에 PHP 아티즌을 부팅하면 일시적 부팅 경합으로 커맨드가
// 드물게 실패한다(테스트 정합성 문제 아님 — 토큰 발급 인프라의 병렬 부팅 경합).
// 짧은 백오프로 최대 3회 재시도해 6 워커 병렬에서도 안정화한다.
@@ -0,0 +1,245 @@
/**
* E2E: 탭 왕복 후 먼저 편집한 입력칸에 옛 값이 남는 결함 (engine-v1.54.7)
*
* 시나리오 매니페스트: `tests/scenarios/localinit-tab-roundtrip-store-sync.yaml`
* (케이스 마킹은 각 test 의 docblock 에 있다 — 파일 헤더에 두면 축 값이 비어 매칭되지 않는다)
*
* @effects earlier_edited_field_resets_to_server_value_after_tab_roundtrip,
* last_edited_field_still_resets_after_tab_roundtrip,
* untouched_field_unaffected_by_tab_roundtrip,
* prune_never_reintroduces_values,
* readonly_permission_disables_inputs,
* tab_roundtrip_emits_no_console_errors
*
* 배경: `_localInit` 적용 여부를 전역 해시(`__g7LocalInitTracking`)로만 판정했는데, 실제 리셋
* 대상인 `localDynamicState`(저장소 A)는 렌더러 인스턴스별이다. 먼저 effect 가 도는 인스턴스가
* 전역 토큰을 소비하면 나머지 루트 렌더러의 저장소 A 는 리셋되지 않고, 병합이 A 우선이라
* 갱신된 저장소 B 위에 stale A 가 덮였다.
*
* 순서 의존성: 자동바인딩은 키입력마다 `_local` 전체 스냅샷을 A 에 쓰는데 정리는 마지막 leaf 만
* 지운다 → A 에 "직전까지 편집한 필드들"의 사본이 남는다. 그래서 **마지막 편집 칸만** 정상으로
* 보이고 그 이전 칸들만 옛 값이 남는 비대칭이 생겼다.
*
* 재현 조건 3가지가 모두 필요하다:
* ① 폼 데이터소스가 initLocal + refetchOnMount: true
* ② 탭 전환이 URL(?tab=)을 바꿔 remount + refetch 유발
* ③ 되돌아오기 전 2개 이상의 입력칸을 실제 타이핑으로 편집
*
* 대상 화면은 이커머스 환경설정 "주문설정" 탭이다 — 브라우저 계측으로 결함이 실제로 재현된
* 화면이며, 자동바인딩(`name` prop) 숫자 입력칸이 두 개 있어 순서 의존성을 그대로 노출한다.
* 저장은 하지 않으므로 사이트 설정을 바꾸지 않는다.
*
* 단위 테스트는 이 결함을 잡지 못한다 — React 렌더 사이클과 복수 루트 렌더러의 effect 실행
* 순서를 모사하지 못하기 때문이다.
*
* @see .claude/docs/frontend/troubleshooting-state-global.md 사례 5
*/
import { test, expect, issueToken, issueScopedToken, authenticatePage } from '../../fixtures/auth';
import type { Page } from '@playwright/test';
const ORDER_TAB = '/admin/ecommerce/settings?tab=order_settings';
const FIELD_A = 'input[name="order_settings.auto_cancel_days"]';
const FIELD_B = 'input[name="order_settings.cart_expiry_days"]';
const SETTINGS_PERMISSIONS = [
'sirsoft-ecommerce.settings.read',
'sirsoft-ecommerce.settings.update',
] as const;
/**
* 주문설정 탭 진입 — 폼 데이터가 `_local` 에 실제로 실릴 때까지 기다린다.
*
* DOM attach 만 기다리면 바인딩 전 빈 값을 서버값으로 오인해 단언이 무의미해진다.
* 엔진의 `getLocal()` 로 폼 적재를 직접 확인한다.
*/
async function gotoOrderTab(page: Page): Promise<void> {
await page.goto(ORDER_TAB);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
// 폼 적재를 먼저 확인한다. DOM attach 만 기다리면 조건부 렌더 입력칸(auto_cancel_days 는
// auto_cancel_expired 토글에 걸려 있다)이 "아직 안 그려진 것"과 "꺼져서 없는 것"을 구분하지
// 못해, 부재 단언이 렌더 전에 통과해 버린다.
await expect
.poll(
async () =>
await page.evaluate(() => {
const local = (window as any).G7Core?.state?.getLocal?.();
return local?.form?.order_settings !== undefined;
}),
{ timeout: 20_000 }
)
.toBe(true);
await expect(page.locator(FIELD_A)).toBeAttached({ timeout: 20_000 });
await expect(page.locator(FIELD_B)).toBeAttached({ timeout: 20_000 });
}
/**
* 서버값과 반드시 다른 숫자 문자열을 만든다 — 같은 값을 넣으면 리셋 여부를 구분할 수 없다.
* 두 필드의 허용 범위(1~30 / 1~365) 안에 함께 들어가는 작은 수만 쓴다.
*/
function other(current: string): string {
return current.trim() === '7' ? '9' : '7';
}
/** 실제 타이핑으로 값을 바꾼다 — fill 은 자동바인딩의 키입력 경로를 그대로 타지 않는다 */
async function typeInto(page: Page, selector: string, value: string): Promise<void> {
const input = page.locator(selector);
await input.click();
await input.press('ControlOrMeta+a');
await input.pressSequentially(value, { delay: 30 });
await input.blur();
}
/**
* 다른 탭에 갔다가 주문설정 탭으로 돌아온다.
*
* `page.goto` 를 쓰면 안 된다 — 전체 새로고침이라 모든 렌더러가 새로 마운트되고 전역 추적도
* 초기화되어 재현 조건이 통째로 사라진다(실측: goto 왕복으로는 수정 전에도 결함이 안 뜬다).
* 결함은 SPA 라우팅(탭 클릭)으로 URL 만 바뀌어 보존 컴포넌트의 저장소 A 가 살아남을 때 드러난다.
*/
async function tabRoundTrip(page: Page): Promise<void> {
await page.getByRole('tab', { name: '마일리지' }).click();
await expect(page).toHaveURL(/tab=mileage/, { timeout: 15_000 });
await expect(page.locator(FIELD_B)).toHaveCount(0, { timeout: 15_000 });
await page.getByRole('tab', { name: '주문설정' }).click();
await expect(page).toHaveURL(/tab=order_settings/, { timeout: 15_000 });
await expect(page.locator(FIELD_B)).toBeAttached({ timeout: 20_000 });
await expect(page.locator(FIELD_A)).toBeAttached({ timeout: 20_000 });
}
/**
* 이 스펙은 직렬로 실행한다 (@since engine-v1.54.8).
*
* 5개 테스트가 모두 관리자 이커머스 환경설정 화면을 띄우는데, 병렬(워커 5)로 돌리면
* `_local` 폼 적재(20초 poll)가 타임아웃되어 전건이 진입 단계에서 실패한다 —
* 실측: `--workers=5` → 3~5건 실패 / `--workers=1` → 5건 통과, 동일 빌드·동일 코드.
* 실패 시 4xx/5xx 응답은 0이고 화면은 렌더되므로 제품 결함이 아니라 동시 세션 부하다.
* 직렬 고정을 빼면 이 스펙은 회귀를 잡는 대신 거짓 실패를 만들어낸다.
*/
test.describe.configure({ mode: 'serial' });
test.describe('탭 왕복 후 저장소 A/B 동기화 (engine-v1.54.7)', () => {
/**
* @scenario edit_order=first_edited
*
* @effects earlier_edited_field_resets_to_server_value_after_tab_roundtrip
*/
test('두 칸을 편집하고 탭을 왕복하면 먼저 편집한 칸도 서버값으로 돌아온다', async ({ page }) => {
await authenticatePage(page, issueToken(...SETTINGS_PERMISSIONS));
await gotoOrderTab(page);
// 저장하지 않은 상태의 서버값 — 왕복 후 이 값으로 돌아와야 한다
const serverA = await page.locator(FIELD_A).inputValue();
const serverB = await page.locator(FIELD_B).inputValue();
const typedA = other(serverA);
const typedB = other(serverB);
await typeInto(page, FIELD_A, typedA); // ← 먼저 편집 (수정 전 결함 대상)
await typeInto(page, FIELD_B, typedB); // ← 마지막 편집 (수정 전에도 정상)
await expect(page.locator(FIELD_A)).toHaveValue(typedA);
await expect(page.locator(FIELD_B)).toHaveValue(typedB);
await tabRoundTrip(page);
// 수정 전에는 FIELD_A 만 typedA 가 남아 화면과 저장값이 어긋났다
await expect(page.locator(FIELD_A)).toHaveValue(serverA);
await expect(page.locator(FIELD_B)).toHaveValue(serverB);
});
/**
* @scenario edit_order=last_edited
*
* @effects last_edited_field_still_resets_after_tab_roundtrip
*/
test('입력 순서를 뒤집어도 두 칸 모두 서버값으로 돌아온다 (순서 비의존)', async ({ page }) => {
await authenticatePage(page, issueToken(...SETTINGS_PERMISSIONS));
await gotoOrderTab(page);
const serverA = await page.locator(FIELD_A).inputValue();
const serverB = await page.locator(FIELD_B).inputValue();
// 앞선 테스트와 순서만 뒤집는다 — 결함이 입력 순서를 따라 움직이던 축을 고정한다
await typeInto(page, FIELD_B, other(serverB));
await typeInto(page, FIELD_A, other(serverA));
await tabRoundTrip(page);
await expect(page.locator(FIELD_A)).toHaveValue(serverA);
await expect(page.locator(FIELD_B)).toHaveValue(serverB);
});
/**
* @scenario edit_order=untouched
*
* @effects untouched_field_unaffected_by_tab_roundtrip, prune_never_reintroduces_values
*/
test('편집하지 않은 칸은 탭 왕복 전후로 값이 변하지 않는다 (제거가 값을 도입하지 않음)', async ({
page,
}) => {
await authenticatePage(page, issueToken(...SETTINGS_PERMISSIONS));
await gotoOrderTab(page);
const untouchedBefore = await page.locator(FIELD_B).inputValue();
// 한 칸만 편집 → 나머지 칸은 손대지 않는다
const serverA = await page.locator(FIELD_A).inputValue();
await typeInto(page, FIELD_A, other(serverA));
await tabRoundTrip(page);
await expect(page.locator(FIELD_B)).toHaveValue(untouchedBefore);
await expect(page.locator(FIELD_A)).toHaveValue(serverA);
});
/**
* @scenario edit_order=untouched
*
* @effects readonly_permission_disables_inputs
*/
test('읽기 전용 권한에서는 입력칸이 비활성이라 저장소 오염 경로 자체가 없다', async ({ page }) => {
// issueScopedToken 필수 — 기본 issueToken 은 admin 역할을 함께 부여해(전체 권한 보유)
// 권한을 좁혀 넘겨도 화면이 최대 권한으로 렌더되므로 이 분기를 만들 수 없다.
await authenticatePage(page, issueScopedToken('sirsoft-ecommerce.settings.read'));
await gotoOrderTab(page);
await expect(page.locator(FIELD_A)).toBeDisabled();
await expect(page.locator(FIELD_B)).toBeDisabled();
const before = await page.locator(FIELD_A).inputValue();
const beforeB = await page.locator(FIELD_B).inputValue();
// 권한 밖 탭(마일리지)은 403 이므로, 읽기 권한으로 접근 가능한 탭으로 왕복한다.
await page.getByRole('tab', { name: '클레임' }).click();
await expect(page).toHaveURL(/tab=claim/, { timeout: 15_000 });
await page.getByRole('tab', { name: '주문설정' }).click();
await expect(page).toHaveURL(/tab=order_settings/, { timeout: 15_000 });
await expect(page.locator(FIELD_A)).toHaveValue(before);
await expect(page.locator(FIELD_B)).toHaveValue(beforeB);
await expect(page.locator(FIELD_A)).toBeDisabled();
});
/**
* @scenario edit_order=last_edited
*
* @effects tab_roundtrip_emits_no_console_errors
*/
test('탭 왕복 중 콘솔 에러가 발생하지 않는다', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await authenticatePage(page, issueToken(...SETTINGS_PERMISSIONS));
await gotoOrderTab(page);
const serverA = await page.locator(FIELD_A).inputValue();
await typeInto(page, FIELD_A, other(serverA));
await tabRoundTrip(page);
expect(errors).toEqual([]);
});
});
@@ -0,0 +1,118 @@
<?php
namespace Tests\Unit\Testing;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\TestCase;
/**
* 확장 TestCase 의 마이그레이션 경로 선언 회귀 테스트
*
* 배경 (troubleshooting-backend.md 사례 21):
* `RefreshDatabase` 는 프로세스당 한 번만 fresh 마이그레이션을 수행하므로, 그 프로세스에서
* **가장 먼저 실행된 TestCase 의 `migrateFreshUsing()` 이 스위트 전체의 스키마를 확정**한다.
* 자기 확장 경로만 넘기는 TestCase 가 먼저 실행되면 뒤따르는 확장의 테이블이 아예 생성되지
* 않아 "Base table or view not found" 가 무더기로 난다.
*
* 이 결함은 **여러 확장 스위트를 한 명령으로 묶어 돌릴 때만** 드러난다. 각 스위트를 단독으로
* 돌리면 전부 green 이라, 실행 기반 테스트로는 잡히지 않고 CI 조합이 바뀔 때 되살아난다.
* 그래서 선언 자체를 검사한다 — 모든 확장 TestCase 가 번들 확장 전체를 포함해야 한다.
*/
class ExtensionTestCaseMigrationPathsTest extends TestCase
{
/**
* 저장소 루트 절대 경로.
*
* 데이터 프로바이더는 static 이라 애플리케이션 부팅 전에 실행된다 — `base_path()` 를 쓸 수
* 없으므로 이 파일 위치에서 역산한다 (`tests/Unit/Testing/` → 3단계 상위).
*
* @return string 저장소 루트
*/
private static function repositoryRoot(): string
{
return dirname(__DIR__, 3);
}
/**
* 검사 대상: `migrateFreshUsing()` 을 정의한 번들 확장 TestCase 파일.
*
* @return array<string, array{string}> [파일 경로]
*/
public static function extensionTestCaseProvider(): array
{
$patterns = [
'modules/_bundled/*/tests/ModuleTestCase.php',
'plugins/_bundled/*/tests/PluginTestCase.php',
'templates/_bundled/*/tests/TemplateTestCase.php',
];
$cases = [];
foreach ($patterns as $pattern) {
foreach (glob(self::repositoryRoot().DIRECTORY_SEPARATOR.$pattern) ?: [] as $path) {
$source = file_get_contents($path);
// migrateFreshUsing 을 재정의하지 않는 TestCase 는 코어 기본 스키마를 쓰므로 대상 아님
if (! str_contains($source, 'function migrateFreshUsing')) {
continue;
}
$relative = str_replace(self::repositoryRoot().DIRECTORY_SEPARATOR, '', $path);
$cases[$relative] = [$path];
}
}
return $cases;
}
/**
* 모든 확장 TestCase 가 번들 모듈·플러그인 마이그레이션을 전부 포함한다.
*
* @param string $path TestCase 파일 절대 경로
*/
#[DataProvider('extensionTestCaseProvider')]
public function test_extension_test_case_includes_all_bundled_migrations(string $path): void
{
$source = file_get_contents($path);
$relative = str_replace(self::repositoryRoot().DIRECTORY_SEPARATOR, '', $path);
foreach (['modules/_bundled/*/database/migrations', 'plugins/_bundled/*/database/migrations'] as $globPattern) {
$this->assertStringContainsString(
$globPattern,
$source,
"{$relative} 의 migrateFreshUsing() 이 '{$globPattern}' 을 포함하지 않습니다. "
.'이 TestCase 가 프로세스에서 먼저 실행되면 다른 확장의 테이블이 생성되지 않아 '
.'스위트를 묶어 돌릴 때 "Base table or view not found" 가 발생합니다.'
);
}
}
/**
* 자기 확장 경로만 하드코딩한 잔재가 없다.
*
* glob 을 도입하면서 옛 하드코딩 줄을 지우지 않으면 경로가 중복될 뿐 결함은 남지 않지만,
* glob 없이 하드코딩만 있는 상태로 되돌아가는 회귀는 위 단언이 잡는다. 이 단언은 그
* 되돌림이 "자기 확장만 나열" 형태로 재등장하는 것을 추가로 막는다.
*
* @param string $path TestCase 파일 절대 경로
*/
#[DataProvider('extensionTestCaseProvider')]
public function test_extension_test_case_does_not_pin_only_its_own_migrations(string $path): void
{
$source = file_get_contents($path);
$relative = str_replace(self::repositoryRoot().DIRECTORY_SEPARATOR, '', $path);
// 예: 'plugins/sirsoft-ckeditor5/database/migrations' 처럼 와일드카드 없는 단일 확장 경로
$pinned = preg_match(
"#['\"](modules|plugins)/(?!_bundled/\*)[a-z0-9_-]+/database/migrations['\"]#i",
$source
);
$this->assertSame(
0,
$pinned,
"{$relative} 이 특정 확장의 마이그레이션 경로를 직접 지정하고 있습니다. "
.'번들 확장 전체를 glob 으로 포함하세요.'
);
}
}
@@ -0,0 +1,272 @@
<?php
namespace Tests\Unit\Traits;
use App\Traits\NormalizesSettingsData;
use Tests\TestCase;
/**
* 트레이트 검증용 더미 클래스 (protected 메서드 노출)
*/
class NormalizesSettingsDataDummy
{
use NormalizesSettingsData;
public function normalize(array $settings, array $defaults): array
{
return $this->normalizeSettingsData($settings, $defaults);
}
public function normalizeCategory(array $settings, array $defaults): array
{
return $this->normalizeCategoryData($settings, $defaults);
}
}
/**
* 설정 데이터 정규화 트레이트 테스트
*
* 숫자 설정이 문자열로 영속되어도(HTML number 입력 → Laravel integer 규칙은
* 숫자 문자열을 통과시키되 캐스트하지 않음) 조회 시 defaults 스키마의 스칼라 타입으로
* 정규화되는지 검증한다. 정규화가 없으면 Carbon 등 strict 타입 경계에서 TypeError 가 난다.
*
* @effects settings_read_returns_schema_scalar_type
*/
class NormalizesSettingsDataTest extends TestCase
{
private NormalizesSettingsDataDummy $dummy;
protected function setUp(): void
{
parent::setUp();
$this->dummy = new NormalizesSettingsDataDummy;
}
// ──────────────────────────────────────────────
// 스칼라 숫자 정규화 (int)
// ──────────────────────────────────────────────
public function test_numeric_string_is_cast_to_int_when_default_is_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => '5'],
['auto_cancel_days' => 3],
);
$this->assertSame(5, $result['auto_cancel_days']);
}
public function test_zero_padded_numeric_string_is_cast_to_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => '007'],
['auto_cancel_days' => 3],
);
$this->assertSame(7, $result['auto_cancel_days']);
}
public function test_nested_category_settings_are_normalized(): void
{
$result = $this->dummy->normalize(
['order_settings' => ['auto_cancel_days' => '5', 'cart_expiry_days' => '30']],
['order_settings' => ['auto_cancel_days' => 3, 'cart_expiry_days' => 30]],
);
$this->assertSame(5, $result['order_settings']['auto_cancel_days']);
$this->assertSame(30, $result['order_settings']['cart_expiry_days']);
}
// ──────────────────────────────────────────────
// 스칼라 숫자 정규화 (float)
// ──────────────────────────────────────────────
public function test_numeric_string_is_cast_to_float_when_default_is_float(): void
{
$result = $this->dummy->normalizeCategory(
['point_value' => '0.5'],
['point_value' => 1.0],
);
$this->assertSame(0.5, $result['point_value']);
}
public function test_integer_string_is_cast_to_float_when_default_is_float(): void
{
$result = $this->dummy->normalizeCategory(
['point_value' => '2'],
['point_value' => 1.0],
);
$this->assertSame(2.0, $result['point_value']);
}
// ──────────────────────────────────────────────
// 배열 항목 내부 정규화 (currencies 등)
// ──────────────────────────────────────────────
public function test_numeric_strings_inside_indexed_array_items_are_cast(): void
{
$result = $this->dummy->normalizeCategory(
['currencies' => [
['code' => 'KRW', 'decimal_places' => '0', 'exchange_rate' => '1'],
['code' => 'USD', 'decimal_places' => '2', 'exchange_rate' => '1350.5'],
]],
['currencies' => [
['code' => 'KRW', 'decimal_places' => 0, 'exchange_rate' => 1.0],
]],
);
$this->assertSame(0, $result['currencies'][0]['decimal_places']);
$this->assertSame(1.0, $result['currencies'][0]['exchange_rate']);
$this->assertSame(2, $result['currencies'][1]['decimal_places']);
$this->assertSame(1350.5, $result['currencies'][1]['exchange_rate']);
}
// ──────────────────────────────────────────────
// 비회귀 — 캐스트 대상이 아닌 값은 불변
// ──────────────────────────────────────────────
public function test_non_numeric_string_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => 'abc'],
['auto_cancel_days' => 3],
);
$this->assertSame('abc', $result['auto_cancel_days']);
}
public function test_empty_string_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => ''],
['auto_cancel_days' => 3],
);
$this->assertSame('', $result['auto_cancel_days']);
}
public function test_null_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => null],
['auto_cancel_days' => 3],
);
$this->assertNull($result['auto_cancel_days']);
}
public function test_boolean_default_is_not_treated_as_numeric(): void
{
// PHP 에서 true 는 is_int 가 아니지만, 문자열 "1" 이 불리언 설정에 들어와도
// int 로 바뀌면 안 된다 (기본값 타입이 bool 이므로 캐스트 대상 아님).
$result = $this->dummy->normalizeCategory(
['auto_cancel_expired' => '1'],
['auto_cancel_expired' => true],
);
$this->assertSame('1', $result['auto_cancel_expired']);
}
public function test_string_default_keeps_numeric_string_as_string(): void
{
// 기본값이 문자열이면(예: 사업자번호 조각) 숫자처럼 보여도 문자열을 유지해야 한다.
$result = $this->dummy->normalizeCategory(
['business_number_1' => '007'],
['business_number_1' => ''],
);
$this->assertSame('007', $result['business_number_1']);
}
public function test_default_absent_key_is_left_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['unknown_key' => '5'],
[],
);
$this->assertSame('5', $result['unknown_key']);
}
public function test_int_value_stays_int(): void
{
$result = $this->dummy->normalizeCategory(
['auto_cancel_days' => 5],
['auto_cancel_days' => 3],
);
$this->assertSame(5, $result['auto_cancel_days']);
}
public function test_multilingual_conversion_is_not_broken(): void
{
// 기존 다국어 분기(기본값 배열 + 값 문자열 → 로케일 배열)가 유지되어야 한다.
$result = $this->dummy->normalizeCategory(
['shop_name' => '내 쇼핑몰'],
['shop_name' => ['ko' => '쇼핑몰', 'en' => 'Shop']],
);
$this->assertSame(['ko' => '내 쇼핑몰', 'en' => '내 쇼핑몰'], $result['shop_name']);
}
// ──────────────────────────────────────────────
// 중첩 연관 배열(하위 설정 그룹) 정규화
// ──────────────────────────────────────────────
public function test_numeric_strings_inside_associative_group_are_cast(): void
{
// 카테고리 아래 한 단계 더 깊은 연관 배열도 정규화되어야 한다.
// (인덱스 배열만 내려가던 시절에는 이 값이 문자열로 남아 Carbon 경계까지 갔다)
$result = $this->dummy->normalizeCategory(
['mileage' => ['expiry_days' => '365', 'earn_rate' => '1.5']],
['mileage' => ['expiry_days' => 90, 'earn_rate' => 1.0]],
);
$this->assertSame(365, $result['mileage']['expiry_days']);
$this->assertSame(1.5, $result['mileage']['earn_rate']);
}
public function test_associative_group_normalization_is_recursive(): void
{
$result = $this->dummy->normalizeCategory(
['a' => ['b' => ['c' => '7']]],
['a' => ['b' => ['c' => 1]]],
);
$this->assertSame(7, $result['a']['b']['c']);
}
public function test_multilingual_map_is_untouched_by_group_recursion(): void
{
// 다국어 필드도 연관 배열이지만 기본값이 문자열이라 캐스트 대상이 아니다.
$result = $this->dummy->normalizeCategory(
['shop_name' => ['ko' => '5', 'en' => '5']],
['shop_name' => ['ko' => '쇼핑몰', 'en' => 'Shop']],
);
$this->assertSame(['ko' => '5', 'en' => '5'], $result['shop_name']);
}
public function test_group_without_matching_default_is_untouched(): void
{
$result = $this->dummy->normalizeCategory(
['unknown_group' => ['x' => '5']],
[],
);
$this->assertSame(['x' => '5'], $result['unknown_group']);
}
public function test_numeric_strings_inside_nested_group_of_array_item_are_cast(): void
{
// 인덱스 배열 항목 안에 다시 연관 그룹이 있는 형태
$result = $this->dummy->normalizeCategory(
['rules' => [['code' => 'KRW', 'limits' => ['use_unit' => '100']]]],
['rules' => [['code' => 'KRW', 'limits' => ['use_unit' => 1]]]],
);
$this->assertSame(100, $result['rules'][0]['limits']['use_unit']);
}
}
@@ -0,0 +1,45 @@
feature: localinit_tab_roundtrip_store_sync
description: >
`_localInit` 적용 여부를 전역 해시로만 판정해, 렌더러 인스턴스별 저장소 A(localDynamicState)가
리셋되지 않고 갱신된 저장소 B 를 stale A 가 덮던 결함(engine-v1.54.7)과, 그 후속으로
제거 대상이 없어도 상태 갱신·캐시 무효화가 상시 발생하던 판정 결함(engine-v1.54.8)을 커버한다.
결함은 SPA 라우팅(탭 클릭)에서만 드러난다 — 전체 새로고침은 모든 렌더러를 새로 마운트하고
전역 추적도 초기화하므로 수정 전에도 재현되지 않는다(E2E 가 goto 왕복을 쓰면 false green).
단위 테스트는 React 렌더 사이클과 복수 루트 렌더러의 effect 실행 순서를 모사하지 못하므로
판정 함수·제거 헬퍼의 순수 로직만 잠그고, 인스턴스 간 상호작용은 E2E 가 잠근다.
test_files:
- resources/js/core/template-engine/__tests__/troubleshooting-state-global.test.ts
- tests/Playwright/specs/admin/localinit-tab-roundtrip-store-sync.spec.ts
axes:
# 편집 순서 — 잔존이 화면에 드러나려면 그 칸을 소유한 인스턴스가 리셋을 건너뛴 쪽이어야 한다
edit_order: [first_edited, last_edited, untouched]
effects:
- earlier_edited_field_resets_to_server_value_after_tab_roundtrip
- last_edited_field_still_resets_after_tab_roundtrip
- untouched_field_unaffected_by_tab_roundtrip
- prune_is_noop_when_nothing_to_remove # engine-v1.54.8 — 참조 보존으로 거짓 양성 차단
- prune_never_reintroduces_values # 제거는 값을 도입하지 않는다 (저장소 B 불가침)
- readonly_permission_disables_inputs # 쓰기 권한 없으면 오염 경로 자체가 없다
- tab_roundtrip_emits_no_console_errors # 조용한 실패였음을 고정 (콘솔로는 안 잡혔다)
sub_flows:
- id: readonly_permission
description: 보기 권한만 있으면 입력칸이 비활성이라 저장소 오염 경로 자체가 없다
effects:
- readonly_permission_disables_inputs
- id: console_clean
description: 탭 왕복 중 콘솔 에러 0 (조용한 실패였던 결함이라 콘솔로는 안 잡혔음을 고정)
effects:
- tab_roundtrip_emits_no_console_errors
notes: |
E2E 는 직렬 실행이 필요하다(spec 헤더 `test.describe.configure({ mode: 'serial' })`).
병렬(워커 5)에서는 동일 빌드·동일 코드로도 폼 적재 poll 이 타임아웃되어 거짓 실패가 난다
(실측: workers=5 → 3~5건 실패 / workers=1 → 5건 통과, 4xx·5xx 0건).