fix(core,ecommerce,tosspayments): 간편결제 선택값 유실·기본통화 중복·고아 결제수단 노출 수정
엔진 - dot notation setState(target:"_local.xxx")가 canonical _global._local 을 동기화하지 않아, 화면에는 선택 표시가 되는데 확장 커스텀 핸들러의 getLocal 에는 값이 보이지 않던 비대칭을 해소했다(engine-v1.58.2). 예외도 경고도 없이 값만 비어 발견이 늦다. 동기화 기준을 전체 스냅샷이 아니라 live _global._local + 변경 키로 잡아, 부분 상태가 base 가 되어 다른 키를 잃거나 앞선 setLocal 을 되돌리는 기존 회귀 경로를 피했다. 토스페이먼츠 - 간편결제 자체창 직행 지정을 top-level easyPay 로 넘겨 SDK 가 통째로 무시하던 것을 card.flowMode DIRECT + card.easyPay 정본 계약으로 교정했다. 이커머스 - defaults 에서 보충된 통화가 옛 is_default 를 들여와 기본 통화가 둘이 되면서 환율이 1.0 으로 박제돼 PG 청구액이 1/100 로 나갔다. 병합 직후 재동기화하고, 표시 계층의 기본 통화 해석을 default_currency 우선으로 통일해 환산 계층과 일치시켰다. - 공급 확장이 더 이상 제공하지 않는 고아 결제수단이 주문서에 노출되던 것을 공개 API 단일 지점에서 차단했다. 관리자 응답은 운영자가 확인·삭제해야 하므로 그대로 둔다. - 관리자 주문설정에서 Select 에 준 폭 클래스가 컴포넌트 기본 w-full 에 밀려 죽는 구조라, 폭 책임을 셀렉트에서 열 컨테이너로 옮겼다. - 고아 행에서 마일리지 차감 시점 열만 가드가 빠져 노출되던 사전 결함을 함께 고쳤다. 열을 열거하는 대신 조상 체인의 가드 유무를 스캔해, 이후 추가되는 열도 자동으로 검사 대상이 되도록 회귀 테스트를 구성했다. 개발 도구 - dev-dashboard 인스톨러 초기화에 환경설정/첨부파일 삭제 토글을 추가했다. 컨테이너 디렉토리를 보존해 재생성 시 소유권이 바뀌지 않게 하고, 두 옵션이 같은 경로를 다뤄도 서로 간섭하지 않도록 분리했다. 규정 - 저장값과 확장 훅 카탈로그가 병합되는 설정에서 공개 응답이 카탈로그 소속까지 판정해야 한다는 규칙을 문서화하고, 정적 판정이 불가한 근거와 함께 coverage 에 등록했다.
This commit is contained in:
@@ -387,6 +387,22 @@ Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은
|
||||
|
||||
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
|
||||
|
||||
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
|
||||
|
||||
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|------|------------|
|
||||
| 공개 응답이 저장값 플래그(`is_active`)만 보고 항목을 내보냄 | 카탈로그 소속(`_orphaned`)을 함께 판정 — 공급 확장이 더 이상 제공하지 않는 항목은 공개 응답에서 제거 |
|
||||
| 고아 항목을 관리자 응답에서도 제거 | 관리자 응답은 유지 — 운영자가 확인하고 지워야 할 대상이다 |
|
||||
| 소비 화면(레이아웃 JSON)마다 필터를 넣어 차단 | 공개 API 단일 지점에서 차단 — 화면마다 복제하면 템플릿 하나만 빠져도 같은 결함이 남는다 |
|
||||
| 같은 데이터를 내보내는 공개 엔드포인트가 서로 다른 게터를 사용 | 전 엔드포인트가 같은 공개 게터를 경유 — 한쪽이 raw `getSettings()` 를 쓰면 그 경로만 조용히 뚫린다 |
|
||||
| 항목 제거 후 배열 인덱스를 그대로 둠 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
|
||||
|
||||
이 결함은 예외도 경고도 로그도 남기지 않는다. 이미 제공 불가한 항목이 사용자 화면에서 선택 가능한 상태로 남아 있는 것이 유일한 증상이고, 관리자 화면은 고아 표시로 정상 차단하고 있어 양쪽을 나란히 보지 않으면 드러나지 않는다.
|
||||
|
||||
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
|
||||
|
||||
### 목록 조회 컬럼 프루닝과 지연 조인
|
||||
|
||||
| 금지 | 올바른 사용 |
|
||||
|
||||
@@ -110,6 +110,7 @@
|
||||
#### 화면 동작
|
||||
|
||||
- 화면에서 선택·클릭 같은 동작에 반응하도록 지정한 항목이 아무 반응도 하지 않던 문제를 수정했습니다. 오류나 경고 없이 조용히 동작하지 않아 원인을 찾기 어려웠습니다. 게시판 글쓰기의 분류 선택이 이에 해당해, 분류를 골라도 글에 반영되지 않았습니다. 함께, 반응은 하되 선택한 값만 비어 저장되던 경우도 수정했습니다.
|
||||
- 화면에서 고른 값이 모듈·플러그인이 제공하는 동작에는 전달되지 않던 문제를 수정했습니다. 값을 저장하는 방식이 두 가지인데 그중 한 가지로 저장하면 화면에는 선택한 것으로 표시되지만 확장 기능은 그 값을 읽지 못했고, 오류나 경고가 남지 않아 화면만 봐서는 알 수 없었습니다. 이제 어느 방식으로 저장하든 같은 값이 전달됩니다.
|
||||
|
||||
#### 레이아웃 코드 편집
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
6. [API 라우트 설정](#6-api-라우트-설정)
|
||||
7. [레이아웃 연동](#7-레이아웃-연동)
|
||||
8. [백엔드에서 설정 조회](#8-백엔드에서-설정-조회)
|
||||
9. [관련 문서](#9-관련-문서)
|
||||
9. [카탈로그 병합 설정의 공개 응답](#9-카탈로그-병합-설정의-공개-응답)
|
||||
10. [관련 문서](#10-관련-문서)
|
||||
|
||||
---
|
||||
|
||||
@@ -527,7 +528,72 @@ $timing = $this->settingsService->getStockDeductionTiming($method);
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 문서
|
||||
## 9. 카탈로그 병합 설정의 공개 응답
|
||||
|
||||
설정 항목의 목록을 다른 확장이 훅으로 등록하는 경우(결제수단, 배송사, 발급 제공자 등), 저장된 값은 그 카탈로그와 병합되어 응답에 실린다. 운영자가 저장한 값(`is_active`, 정렬 순서 등)과 확장이 제공하는 정의(이름, 아이콘, 지원 범위)가 항목마다 합쳐지는 구조다.
|
||||
|
||||
### 9.1 고아 항목이 생기는 조건
|
||||
|
||||
병합의 두 입력은 생명주기가 다르다. 저장값은 파일에 남지만 카탈로그는 요청 시점에 훅으로 다시 만들어지므로, 다음 상황에서 **저장값만 남고 카탈로그에서는 사라진 항목**이 생긴다.
|
||||
|
||||
- 공급 확장을 삭제했다
|
||||
- 공급 확장을 비활성화했다
|
||||
- 확장이 살아 있지만 자기 기능 토글을 꺼서 해당 항목 등록을 중단했다
|
||||
|
||||
병합부는 이런 항목에 `_orphaned` 표시를 붙여 돌려준다. 이때 저장값의 `is_active` 는 참 그대로 남아 있다 — 운영자가 끈 적이 없기 때문이다.
|
||||
|
||||
### 9.2 관리자 응답과 공개 응답의 처리가 다르다
|
||||
|
||||
| 응답 | 고아 항목 | 이유 |
|
||||
| --- | --- | --- |
|
||||
| 관리자 설정 조회 | **포함** | 운영자가 그 항목이 남아 있음을 확인하고 지울 수 있어야 한다. 화면은 `_orphaned` 를 읽어 편집 불가로 표시한다 |
|
||||
| 공개(소비) 조회 | **제거** | 공급 확장이 더 이상 제공하지 않는 항목이다. 내보내면 사용자가 선택할 수 있게 된다 |
|
||||
|
||||
```php
|
||||
public function getPublicPaymentSettings(): array
|
||||
{
|
||||
$orderSettings = $this->getSettings('order_settings');
|
||||
|
||||
if (isset($orderSettings['payment_methods']) && is_array($orderSettings['payment_methods'])) {
|
||||
$orderSettings['payment_methods'] = array_values(array_filter(
|
||||
$orderSettings['payment_methods'],
|
||||
fn ($method) => ! ($method['_orphaned'] ?? false)
|
||||
));
|
||||
}
|
||||
|
||||
return $orderSettings;
|
||||
}
|
||||
```
|
||||
|
||||
`array_values()` 로 인덱스를 재정렬한다. 중간 항목을 지우면 키가 비연속이 되고, 그 배열은 JSON 객체로 직렬화되어 화면의 반복 렌더가 깨진다.
|
||||
|
||||
### 9.3 차단 지점은 공개 API 한 곳이다
|
||||
|
||||
소비 화면(레이아웃 JSON)의 필터에 맡기지 않는다. 같은 데이터를 그리는 템플릿이 여러 개면 필터가 화면 수만큼 복제되고, 한 곳만 빠져도 같은 결함이 그 화면에서만 남는다.
|
||||
|
||||
같은 데이터를 내보내는 공개 엔드포인트가 둘 이상이면 **전부 같은 공개 게터를 경유**해야 한다. 한쪽이 raw `getSettings()` 를 쓰면 그 경로만 조용히 뚫린다.
|
||||
|
||||
```php
|
||||
// ❌ 엔드포인트마다 다른 게터 — checkout 경로만 고아 항목이 새어 나간다
|
||||
$orderSettings = $this->settingsService->getSettings('order_settings');
|
||||
|
||||
// ✅ 공개 엔드포인트는 모두 공개 게터를 경유
|
||||
$orderSettings = $this->settingsService->getPublicPaymentSettings();
|
||||
```
|
||||
|
||||
### 9.4 진단
|
||||
|
||||
이 결함은 예외도 경고도 로그도 남기지 않는다. 관리자 화면은 고아 표시로 정상 차단하고 있어, 관리자 응답과 공개 응답을 나란히 놓고 항목 수를 비교하기 전에는 드러나지 않는다.
|
||||
|
||||
```bash
|
||||
# 두 응답의 항목 id 목록이 다르면, 그 차이가 고아 항목이다
|
||||
GET /api/modules/{id}/admin/settings # 고아 포함 (정상)
|
||||
GET /api/modules/{id}/settings/payment # 고아 제외 (정상)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 관련 문서
|
||||
|
||||
- [모듈 기초](module-basics.md) - 모듈 구조, AbstractModule
|
||||
- [모듈 라우트](module-routing.md) - API 라우트 규칙
|
||||
|
||||
@@ -97,6 +97,11 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 기본 통화를 바꾼 뒤 통화 설정을 저장한 상점에서, 결제창에 청구되는 금액이 주문서에 표시된 금액과 다르게 넘어가던 문제를 수정했습니다. 이전 기본 통화가 기본 통화 표시를 계속 유지해 상점에 기본 통화가 둘로 인식됐고, 그 통화의 환율이 1로 간주되어 환산 없이 청구됐습니다. 예를 들어 기본 통화를 엔화로 바꾼 상점에서 23,000엔 주문이 결제창에는 230원으로 넘어갔습니다. 이제 환율을 입력하지 않은 통화로는 결제가 진행되지 않고 미지원 통화로 안내됩니다.
|
||||
- 같은 화면에서 상품별 금액과 총액의 다른 통화 표기가 서로 다른 기준으로 환산되던 문제를 함께 수정했습니다. 위 문제와 원인이 같으며, 주문서의 상품 금액과 총 결제금액의 외화 표기가 어긋나 보였습니다.
|
||||
- 결제대행사 플러그인이 더 이상 제공하지 않는 결제수단이 주문서에 계속 노출되던 문제를 수정했습니다. 예를 들어 토스페이먼츠의 주문서형 결제를 끄면 관리자 환경설정에서는 해당 결제수단이 즉시 사용 불가로 바뀌는데, 주문서에서는 그대로 선택할 수 있었습니다. 플러그인을 삭제하거나 비활성화한 경우에도 동일했습니다. 이제 관리자 화면과 주문서가 같은 기준으로 판단합니다.
|
||||
- 이커머스 환경설정 주문설정의 결제수단 목록에서, 결제대행사를 아직 고르지 않은 줄의 선택 상자가 지나치게 좁게 표시되던 문제를 수정했습니다. 선택 상자 폭이 선택된 값의 글자 수를 따라가 줄마다 항목 위치도 어긋나 있었습니다. 이제 결제대행사·재고 차감 시점·마일리지 차감 시점이 모든 줄에서 같은 폭으로 정렬됩니다.
|
||||
- 같은 목록에서 더 이상 사용할 수 없는 결제수단(공급 플러그인을 지웠거나 끈 경우) 줄에 "마일리지 차감 시점" 항목만 남아 표시되던 문제를 수정했습니다. 다른 설정 항목은 모두 감춰지는데 이 항목만 남아 엉뚱한 위치에 보였고, 마일리지 기능을 사용하는 상점에서는 이미 쓸 수 없는 결제수단의 값을 바꿔 저장할 수도 있었습니다. 화면을 좁게 볼 때는 정상적으로 감춰지고 있어 넓은 화면에서만 나타났습니다.
|
||||
- 기본 통화가 원화가 아닌 상점에서 금액에 원화 단위가 붙던 문제를 수정했습니다. 마일리지 적립·사용·복원·소멸 내역, 결제금액 불일치 안내, 상품 일괄수정 요약, 할인코드 안내가 이제 상점에 설정된 통화의 단위로 표시됩니다.
|
||||
- 통합 검색 결과의 상품 가격이 상점 기본 통화가 아닌 원화 단위로 표기되던 문제를 수정했습니다.
|
||||
- 관리자에게 발송되는 신규 주문 알림의 금액이 구매자 알림과 달리 원화로 고정되던 문제를 수정했습니다. 이제 두 알림 모두 결제 통화로 표시됩니다.
|
||||
|
||||
@@ -55,7 +55,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) |
|
||||
| available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) |
|
||||
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) |
|
||||
| _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | <!-- TODO: 설명 --> |
|
||||
| _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | 설정 화면 전용 메타. `limits` 는 숫자 입력의 경계값 맵(`config('sirsoft-ecommerce.limits')`)으로, 저장 규칙(FormRequest)과 같은 출처다 — 화면이 리터럴 경계를 들면 규칙 변경을 따라가지 못해 "화면은 받는데 저장은 422" 가 되므로 입력 min/max 는 이 값을 바인딩한다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -1447,11 +1447,37 @@ Authorization: Bearer {YOUR_TOKEN}
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| category | string | `"basic_info"` | 조회한 설정 카테고리 (path 파라미터를 그대로 반향) |
|
||||
| settings | object | `{"shop_name":"","route_path":"shop","no_route":false}` | 해당 카테고리의 설정값. 구조는 카테고리마다 다르며 index 응답의 동명 최상위 키와 동일하다 (`order_settings` 조회 시 결제수단은 병합된 목록 — 공급 확장이 빠진 고아 항목 포함) |
|
||||
| abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update — 권한 맵 기반) |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "설정을 조회했습니다.",
|
||||
"data": {
|
||||
"category": "basic_info",
|
||||
"settings": {
|
||||
"shop_name": "",
|
||||
"route_path": "shop",
|
||||
"no_route": false
|
||||
},
|
||||
"abilities": {
|
||||
"can_update": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
@@ -1491,7 +1517,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| shipping | object | `{"default_country":"KR","available_countries":[{"code":"K…` | 체크아웃용 배송 설정 (기본 국가·배송 가능 국가·무료배송·배송유형 등) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 체크아웃용 주문/결제 설정 (기본 PG·활성 결제수단·무통장 계좌 등). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -1626,7 +1652,7 @@ _단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외) |
|
||||
| order_settings | object | `{"default_pg_provider":null,"cash_receipt_provider":"toss…` | 공개 가능한 결제 설정 (활성 결제수단·무통장 은행명 매핑 포함, 민감 정보 제외). `payment_methods` 는 현재 제공 가능한 결제수단만 포함하며, 공급 확장이 더 이상 제공하지 않는 결제수단(관리자 화면의 고아 항목)은 `is_active` 가 참이어도 제외된다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
+153
@@ -426,6 +426,74 @@ describe('결제수단 Sortable 리스트 구조 검증 (_payment_methods_list.j
|
||||
expect(action.params['form.order_settings.payment_methods']).toContain('is_active');
|
||||
});
|
||||
|
||||
// ─── 필드 열 고정폭 (PG 셀렉트 폭 붕괴 회귀 차단) ───
|
||||
// 배경: Select 는 커스텀 드롭다운이라 className 이 내부 Button 에 붙는데, 컴포넌트
|
||||
// 기본 클래스의 w-full 이 CSS 출력 순서상 w-N 보다 뒤라 셀렉트에 직접 준 폭 토큰은
|
||||
// 무효가 된다(같은 특이도 → 후순위 승리). 그 결과 폭이 내용 폭을 따라가, PG 미선택
|
||||
// 상태('---')에서 74px 로 붕괴하고 선택값 길이에 따라 행마다 열 위치가 어긋났다.
|
||||
// 폭은 열 컨테이너가 책임진다.
|
||||
const FIELD_COLUMN_LABELS = [
|
||||
'payment_methods.pg_provider',
|
||||
'payment_methods.stock_deduction_timing',
|
||||
'payment_methods.mileage_deduction_timing',
|
||||
];
|
||||
|
||||
/** 라벨 i18n 키로 필드 열 컨테이너를 찾는다. */
|
||||
const findFieldColumn = (labelKey: string) =>
|
||||
findFirst(tpl, (n: any) =>
|
||||
typeof n?.props?.className === 'string'
|
||||
&& n.props.className.includes('row-stack')
|
||||
&& Array.isArray(n?.children)
|
||||
&& n.children.some((c: any) => typeof c?.text === 'string' && c.text.includes(labelKey)),
|
||||
);
|
||||
|
||||
it.each(FIELD_COLUMN_LABELS)('필드 열(%s)이 고정폭을 가져야 한다', (labelKey) => {
|
||||
const column = findFieldColumn(labelKey);
|
||||
expect(column).not.toBeNull();
|
||||
expect(
|
||||
column.props.className,
|
||||
`${labelKey} 열에 고정폭이 없으면 셀렉트 폭이 내용 폭을 따라가 미선택 상태에서 붕괴한다`,
|
||||
).toMatch(/\bw-\d+\b/);
|
||||
});
|
||||
|
||||
it('세 필드 열이 동일한 폭 토큰을 사용해야 한다 (행 간 열 정렬)', () => {
|
||||
const widths = FIELD_COLUMN_LABELS.map((labelKey) => {
|
||||
const column = findFieldColumn(labelKey);
|
||||
return (column.props.className.match(/\bw-\d+\b/) ?? [])[0];
|
||||
});
|
||||
expect(new Set(widths).size, `열 폭이 서로 다르면 행 간 열이 어긋난다: ${widths.join(', ')}`).toBe(1);
|
||||
});
|
||||
|
||||
it('필드 열이 축소 가능해야 한다 (좁은 폭에서 행 넘침 차단)', () => {
|
||||
// shrink-0 을 붙이면 고정폭 3열(총 432px)이 줄어들지 않아 좁은 뷰포트에서
|
||||
// 이름 열이 0 까지 붕괴하고도 행이 넘친다 (1100px 실측 17px 초과).
|
||||
for (const labelKey of FIELD_COLUMN_LABELS) {
|
||||
const column = findFieldColumn(labelKey);
|
||||
expect(column.props.className, `${labelKey} 열`).not.toContain('shrink-0');
|
||||
}
|
||||
});
|
||||
|
||||
it('필드 열의 Select 에는 폭 클래스를 주지 않아야 한다 (컴포넌트 기본 w-full 에 덮여 무효)', () => {
|
||||
const selects = [
|
||||
'pg_provider',
|
||||
'stock_deduction_timing',
|
||||
'mileage_deduction_timing',
|
||||
].map((field) =>
|
||||
findFirst(tpl, (n: any) =>
|
||||
n?.name === 'Select' && typeof n?.props?.value === 'string'
|
||||
&& n.props.value.includes(field),
|
||||
),
|
||||
);
|
||||
|
||||
for (const select of selects) {
|
||||
expect(select).not.toBeNull();
|
||||
expect(
|
||||
select.props.className ?? '',
|
||||
'Select 에 준 w-N 은 컴포넌트 기본 w-full 에 덮여 무효 — 죽은 클래스가 폭을 지정한 것처럼 오해를 준다',
|
||||
).not.toMatch(/\bw-\d+\b/);
|
||||
}
|
||||
});
|
||||
|
||||
it('고아 항목 삭제 버튼이 _orphaned 조건에서만 표시되어야 한다', () => {
|
||||
const deleteBtn = findFirst(tpl, (n: any) =>
|
||||
n?.name === 'Button' && typeof n?.if === 'string'
|
||||
@@ -825,6 +893,91 @@ describe('마일리지 차감 시점 결제수단별 컨트롤', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 고아 항목의 편집 컨트롤 차단 ───
|
||||
|
||||
describe('고아 결제수단 행의 편집 컨트롤 차단', () => {
|
||||
// 고아 항목 = 저장값은 남아 있으나 공급 확장이 더 이상 제공하지 않는 결제수단
|
||||
// (플러그인 삭제·비활성, 또는 그 확장이 자기 기능 토글을 끈 경우).
|
||||
// 이 행은 "지우세요" 만 제시해야 하므로 편집 컨트롤이 하나도 노출되면 안 된다.
|
||||
// 삭제 버튼(Button)은 반대로 고아일 때만 나오는 것이 정상이라 대상에서 제외한다.
|
||||
//
|
||||
// 회귀 배경: 리스트 파셜의 마일리지 차감 시점 열만 가드가 빠져 있었다. 다른 열이
|
||||
// 사라지면서 그 열이 오른쪽으로 밀려 최소 주문금액 자리에 표시됐고, 마일리지 기능을
|
||||
// 켠 상점에서는 이미 사라진 결제수단의 값을 바꿔 저장할 수 있었다. 같은 화면의
|
||||
// 카드 파셜은 편집 영역을 하나의 가드 컨테이너로 감싸 정상 차단하고 있었다.
|
||||
const EDIT_CONTROL_NAMES = ['Select', 'Input', 'Toggle', 'Checkbox', 'Textarea'];
|
||||
|
||||
const ORPHAN_GUARD = '!$method._orphaned';
|
||||
|
||||
/**
|
||||
* 편집 컨트롤마다 (조상 체인에 고아 가드가 있는가) 를 판정해 수집한다.
|
||||
*
|
||||
* 가드가 어느 깊이에 걸려 있든 통과시킨다 — 리스트는 열 컨테이너마다, 카드는
|
||||
* 편집 영역 전체를 감싼 컨테이너 한 곳에 걸려 있어 구조가 서로 다르기 때문이다.
|
||||
* 열 목록을 손으로 열거하지 않으므로 이후 추가되는 열도 자동으로 검사 대상이 된다.
|
||||
*/
|
||||
function collectEditControls(node: any, guarded = false, acc: any[] = []): any[] {
|
||||
if (!node || typeof node !== 'object') return acc;
|
||||
|
||||
const nowGuarded = guarded
|
||||
|| (typeof node.if === 'string' && node.if.includes(ORPHAN_GUARD));
|
||||
|
||||
if (EDIT_CONTROL_NAMES.includes(node.name)) {
|
||||
acc.push({ node, guarded: nowGuarded });
|
||||
}
|
||||
|
||||
for (const child of node.children ?? []) {
|
||||
collectEditControls(child, nowGuarded, acc);
|
||||
}
|
||||
if (node.itemTemplate) {
|
||||
collectEditControls(node.itemTemplate, nowGuarded, acc);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
/** 컨트롤을 사람이 읽을 수 있게 식별한다 (실패 메시지용). */
|
||||
function describeControl(entry: any): string {
|
||||
const value = entry.node.props?.value ?? entry.node.props?.checked ?? '';
|
||||
return `${entry.node.name}(${String(value).slice(0, 60) || 'no-value'})`;
|
||||
}
|
||||
|
||||
it.each([
|
||||
['리스트 파셜', paymentMethodsList],
|
||||
['카드 파셜', paymentMethodsCards],
|
||||
])('%s — 모든 편집 컨트롤이 고아 가드 아래에 있어야 한다', (_label, partial) => {
|
||||
const controls = collectEditControls(partial);
|
||||
|
||||
// 컨트롤을 하나도 못 찾았다면 탐색이 실패한 것이다 (부재 단언의 거짓 통과 차단)
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
|
||||
const unguarded = controls.filter((c) => !c.guarded).map(describeControl);
|
||||
expect(
|
||||
unguarded,
|
||||
`고아 행에 편집 컨트롤이 노출된다: ${unguarded.join(', ')} — 조상 체인 어딘가에 if "${ORPHAN_GUARD}" 가 필요하다`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('두 파셜이 같은 수의 편집 컨트롤을 가드해야 한다 (표시 방식 간 동작 일치)', () => {
|
||||
const listGuarded = collectEditControls(paymentMethodsList).filter((c) => c.guarded);
|
||||
const cardsGuarded = collectEditControls(paymentMethodsCards).filter((c) => c.guarded);
|
||||
|
||||
// 넓은 화면(리스트)과 좁은 화면(카드)은 같은 데이터를 그린다. 한쪽만 가드가 빠지면
|
||||
// 창 너비에 따라 편집 가능 여부가 달라진다.
|
||||
expect(listGuarded.length).toBeGreaterThan(0);
|
||||
expect(cardsGuarded.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('삭제 버튼은 고아일 때만 나오므로 편집 컨트롤 가드 대상이 아니다', () => {
|
||||
const deleteBtn = findFirst(paymentMethodsList, (n: any) =>
|
||||
n?.name === 'Button' && typeof n?.if === 'string'
|
||||
&& n.if.includes('$method._orphaned')
|
||||
&& !n.if.includes(ORPHAN_GUARD),
|
||||
);
|
||||
expect(deleteBtn).not.toBeNull();
|
||||
expect(EDIT_CONTROL_NAMES).not.toContain('Button');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 다국어 키 종합 검증 ───
|
||||
|
||||
describe('다국어 키 종합 검증', () => {
|
||||
|
||||
+6
-7
@@ -111,11 +111,12 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "PG 열 고정폭 — Select 는 커스텀 드롭다운이라 className 이 내부 Button 에 붙는데, 컴포넌트 기본 클래스의 w-full 이 CSS 출력 순서상 w-N 보다 뒤라 셀렉트에 직접 준 폭은 무효가 된다. 폭은 열 컨테이너가 책임진다 (미선택 '---' 상태에서 74px 로 붕괴하던 회귀 차단 + 선택값 길이에 따라 행마다 열 위치가 어긋나던 문제 해소).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!$method._orphaned}}",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
"className": "row-stack w-36"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -133,7 +134,7 @@
|
||||
"if": "{{$method.pg_locked}}",
|
||||
"props": {
|
||||
"data-testid": "{{'pg-locked-badge-' + $method.id}}",
|
||||
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
"className": "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300 truncate"
|
||||
},
|
||||
"text": "{{$t('sirsoft-ecommerce.admin.settings.order_settings.payment_methods.pg_fixed') + ' · ' + (((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider))?.name ? $localized(((_local.form?.available_pg_providers ?? []).find(p => p.id === $method.pg_provider)).name) : ($method.pg_provider ?? ''))}}"
|
||||
},
|
||||
@@ -144,7 +145,6 @@
|
||||
"props": {
|
||||
"data-testid": "{{'pg-select-' + $method.id}}",
|
||||
"value": "{{$method.pg_provider ?? ''}}",
|
||||
"className": "w-36",
|
||||
"options": "{{[{value: '', label: '---'}].concat((_local.form?.available_pg_providers ?? []).filter(p => (p.supported_methods ?? []).includes($method.id)).map(p => ({value: p.id, label: $localized(p.name)})))}}",
|
||||
"disabled": "{{_computed.isReadOnly}}"
|
||||
},
|
||||
@@ -187,7 +187,7 @@
|
||||
"name": "Div",
|
||||
"if": "{{!$method._orphaned}}",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
"className": "row-stack w-36"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -203,7 +203,6 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{$method.stock_deduction_timing ?? 'payment_complete'}}",
|
||||
"className": "w-36",
|
||||
"options": [
|
||||
{
|
||||
"value": "order_placed",
|
||||
@@ -237,8 +236,9 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!$method._orphaned}}",
|
||||
"props": {
|
||||
"className": "row-stack"
|
||||
"className": "row-stack w-36"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -254,7 +254,6 @@
|
||||
"name": "Select",
|
||||
"props": {
|
||||
"value": "{{$method.mileage_deduction_timing ?? 'payment_complete'}}",
|
||||
"className": "w-36",
|
||||
"options": [
|
||||
{
|
||||
"value": "order_placed",
|
||||
|
||||
+1
-1
@@ -132,7 +132,7 @@ class EcommerceSettingsController extends PublicBaseController
|
||||
$this->logApiUsage('settings.checkout');
|
||||
|
||||
$shippingSettings = $this->settingsService->getSettings('shipping');
|
||||
$orderSettings = $this->settingsService->getSettings('order_settings');
|
||||
$orderSettings = $this->settingsService->getPublicPaymentSettings();
|
||||
|
||||
return ResponseHelper::moduleSuccess(
|
||||
'sirsoft-ecommerce',
|
||||
|
||||
+17
-4
@@ -132,11 +132,27 @@ trait HasMultiCurrencyPrices
|
||||
/**
|
||||
* 기본 통화 코드를 반환합니다.
|
||||
*
|
||||
* 기본 통화의 SSoT 는 설정의 `default_currency` 값이고 통화 항목의 `is_default` 는 그 파생입니다.
|
||||
* 표시 계층이 `is_default` 를 먼저 훑으면, 저장본에 없어 defaults 에서 보충된 통화가 옛 플래그를
|
||||
* 달고 들어왔을 때 환산 계층(CurrencyConversionService::getDefaultCurrency)과 서로 다른 통화를
|
||||
* 기본으로 잡습니다. 같은 화면의 금액이 통화별로 어긋나므로 `default_currency` 를 먼저 해석합니다.
|
||||
*
|
||||
* @return string 기본 통화 코드 (예: 'KRW')
|
||||
*/
|
||||
protected function getDefaultCurrencyCode(): string
|
||||
{
|
||||
$currencies = $this->getCurrencySettings();
|
||||
$settings = g7_module_settings('sirsoft-ecommerce', 'language_currency');
|
||||
$declared = $settings['default_currency'] ?? null;
|
||||
|
||||
// 선언된 기본 통화가 통화 목록에 실재할 때만 채택 (삭제된 통화를 가리키는 설정 방어)
|
||||
if ($declared !== null) {
|
||||
foreach ($currencies as $currency) {
|
||||
if (($currency['code'] ?? null) === $declared) {
|
||||
return $declared;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($currencies as $currency) {
|
||||
if ($currency['is_default'] ?? false) {
|
||||
@@ -144,10 +160,7 @@ trait HasMultiCurrencyPrices
|
||||
}
|
||||
}
|
||||
|
||||
// 설정이 없는 경우 모듈 설정에서 직접 조회
|
||||
$settings = g7_module_settings('sirsoft-ecommerce', 'language_currency');
|
||||
|
||||
return $settings['default_currency'] ?? 'KRW';
|
||||
return $declared ?? 'KRW';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -126,6 +126,12 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
);
|
||||
}
|
||||
|
||||
// 보충된 통화가 defaults 의 is_default 를 그대로 들여오면 기본 통화가 복수가 된다.
|
||||
// is_default 의 SSoT 는 default_currency 이므로 병합 직후 전 항목을 재동기화한다.
|
||||
if (isset($settings['language_currency'])) {
|
||||
$settings['language_currency'] = $this->syncCurrencyDefaults($settings['language_currency']);
|
||||
}
|
||||
|
||||
// 결제수단 병합 (기본 + 플러그인 필터 + 사용자 저장 설정)
|
||||
if (isset($settings['order_settings'])) {
|
||||
$settings['order_settings']['payment_methods'] = $this->getMergedPaymentMethods(
|
||||
@@ -188,14 +194,26 @@ class EcommerceSettingsService implements ModuleSettingsInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 결제 설정 조회 (bank_accounts에 은행명 매핑 포함)
|
||||
* 공개 결제 설정 조회 (고아 결제수단 제외 + bank_accounts에 은행명 매핑 포함)
|
||||
*
|
||||
* @return array 은행명이 포함된 결제 설정
|
||||
* 고아 결제수단(저장은 되어 있으나 현재 available 카탈로그에 없는 항목)은
|
||||
* 공급 확장이 사라졌거나 그 확장이 해당 수단 제공을 중단한 상태다. 저장값의
|
||||
* is_active 는 그대로 남아 있으므로 걸러내지 않으면 체크아웃이 선택 가능한
|
||||
* 결제수단으로 계속 노출한다(관리자 화면은 _orphaned 를 읽어 이미 차단).
|
||||
*
|
||||
* @return array 고아 항목이 제거되고 은행명이 포함된 결제 설정
|
||||
*/
|
||||
public function getPublicPaymentSettings(): array
|
||||
{
|
||||
$orderSettings = $this->getSettings('order_settings');
|
||||
|
||||
if (isset($orderSettings['payment_methods']) && is_array($orderSettings['payment_methods'])) {
|
||||
$orderSettings['payment_methods'] = array_values(array_filter(
|
||||
$orderSettings['payment_methods'],
|
||||
fn ($method) => ! ($method['_orphaned'] ?? false)
|
||||
));
|
||||
}
|
||||
|
||||
if (isset($orderSettings['bank_accounts'], $orderSettings['banks'])) {
|
||||
$banks = collect($orderSettings['banks']);
|
||||
$orderSettings['bank_accounts'] = array_map(function ($account) use ($banks) {
|
||||
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Feature\Http\Controllers\Public;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 공개 결제 설정 API 의 고아 결제수단 차단 테스트
|
||||
*
|
||||
* 회귀 배경: 토스페이먼츠 플러그인의 주문서형 결제를 끄면 toss_* 결제수단이 available
|
||||
* 카탈로그에서 빠지지만 저장값의 is_active 는 true 로 남는다. 관리자 화면은 고아 표시로
|
||||
* 차단하는데 체크아웃은 is_active 만 보므로, 공개 API 가 걸러 주지 않으면 비활성화한
|
||||
* 결제수단이 주문서에서 그대로 선택 가능했다. 플러그인 삭제/비활성 시에도 동일하다.
|
||||
*/
|
||||
class PaymentSettingsOrphanedMethodTest extends ModuleTestCase
|
||||
{
|
||||
private const PAYMENT_ENDPOINT = '/api/modules/sirsoft-ecommerce/settings/payment';
|
||||
|
||||
private const CHECKOUT_ENDPOINT = '/api/modules/sirsoft-ecommerce/settings/checkout';
|
||||
|
||||
private const ORPHANED_ID = 'toss_kakaopay';
|
||||
|
||||
private string $storagePath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
|
||||
|
||||
$this->saveOrderSettings([
|
||||
'payment_methods' => [
|
||||
['id' => 'card', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
['id' => 'dbank', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
|
||||
[
|
||||
// 공급 플러그인이 더 이상 제공하지 않는 결제수단 (필터 미등록 → available 부재)
|
||||
'id' => self::ORPHANED_ID,
|
||||
'sort_order' => 121,
|
||||
'is_active' => true,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
'_cached_name' => ['ko' => '카카오페이 (토스페이먼츠)', 'en' => 'KakaoPay (TossPayments)'],
|
||||
'_cached_icon' => 'wallet',
|
||||
'_cached_source' => 'plugin:sirsoft-tosspayments',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (File::isDirectory($this->storagePath)) {
|
||||
File::cleanDirectory($this->storagePath);
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_payment_endpoint_excludes_orphaned_method(): void
|
||||
{
|
||||
$response = $this->getJson(self::PAYMENT_ENDPOINT);
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data.order_settings.payment_methods'), 'id');
|
||||
|
||||
$this->assertNotContains(self::ORPHANED_ID, $ids);
|
||||
$this->assertContains('card', $ids);
|
||||
$this->assertContains('dbank', $ids);
|
||||
}
|
||||
|
||||
public function test_checkout_endpoint_excludes_orphaned_method(): void
|
||||
{
|
||||
$response = $this->getJson(self::CHECKOUT_ENDPOINT);
|
||||
|
||||
$response->assertOk();
|
||||
$ids = array_column($response->json('data.order_settings.payment_methods'), 'id');
|
||||
|
||||
$this->assertNotContains(self::ORPHANED_ID, $ids);
|
||||
$this->assertContains('card', $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* order_settings.json 을 테스트 격리 경로에 기록합니다.
|
||||
*
|
||||
* @param array $settings 저장할 주문설정 배열
|
||||
*/
|
||||
private function saveOrderSettings(array $settings): void
|
||||
{
|
||||
File::ensureDirectoryExists($this->storagePath);
|
||||
File::put(
|
||||
$this->storagePath.'/order_settings.json',
|
||||
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
}
|
||||
+75
@@ -173,6 +173,81 @@ test.describe('@sirsoft-ecommerce 주문설정 결제수단 — PG 고정 표시
|
||||
}
|
||||
});
|
||||
|
||||
// @scenario extension_payment_method:pg_select_for_unlocked
|
||||
// @effects admin_shows_pg_select_for_unlocked
|
||||
test('PG 셀렉트는 미선택 상태에서도 형제 필드 열과 같은 폭으로 렌더된다', async ({
|
||||
page,
|
||||
settingsToken,
|
||||
}) => {
|
||||
// 배경: Select 는 커스텀 드롭다운이라 레이아웃이 준 className 이 내부 button 에 붙는데,
|
||||
// 컴포넌트 기본 클래스의 w-full 이 CSS 출력 순서상 w-N 보다 뒤라 폭 토큰이 무효가 됐다.
|
||||
// 그 결과 폭이 내용 폭을 따라가 미선택('---') 상태에서 74px 로 붕괴했고, 선택값 길이에
|
||||
// 따라 행마다 열 위치가 어긋났다. 폭은 열 컨테이너가 책임진다.
|
||||
//
|
||||
// 측정 규율: 절대 픽셀값을 기대치로 박지 않는다 (폰트·테마·PG 구성에 따라 달라진다).
|
||||
// 같은 행의 형제 필드 열과 **상대 비교**하고, 행 간 열 폭이 일치하는지를 본다.
|
||||
await gotoOrderSettings(page, settingsToken);
|
||||
|
||||
// 고정폭이 유지되는 넓은 뷰포트에서 측정한다. 좁은 폭에서는 세 필드 열이 함께
|
||||
// 비례 축소되므로(행 넘침 차단) 폭의 절대 일치가 성립하지 않는다.
|
||||
await page.setViewportSize({ width: 1600, height: 1000 });
|
||||
|
||||
// 카탈로그 시드(gotoOrderSettings)와 DOM 렌더는 별개다 — 렌더를 기다리지 않으면
|
||||
// 측정 결과가 비어 skip 되고, 그 skip 이 거짓 통과가 된다.
|
||||
await expect(
|
||||
page.locator('[data-testid^="pg-select-"]').first(),
|
||||
'PG 선택 셀렉트가 렌더되지 않아 폭을 측정할 수 없다',
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
const measured = await page.evaluate(() => {
|
||||
const selects = Array.from(document.querySelectorAll('[data-testid^="pg-select-"]'));
|
||||
const rows = selects
|
||||
.map((el) => (el.parentElement as HTMLElement | null)?.parentElement ?? null)
|
||||
.filter((r): r is HTMLElement => r !== null);
|
||||
|
||||
return rows.map((row) => {
|
||||
const columns = Array.from(row.children).filter((c) =>
|
||||
c.className.includes('row-stack'),
|
||||
) as HTMLElement[];
|
||||
const pgSelect = row.querySelector('[data-testid^="pg-select-"]');
|
||||
return {
|
||||
id: pgSelect?.getAttribute('data-testid') ?? '',
|
||||
label: (pgSelect?.querySelector('button') as HTMLElement | null)?.innerText.trim() ?? '',
|
||||
columnWidths: columns.map((c) => Math.round(c.getBoundingClientRect().width)),
|
||||
columnLefts: columns.map((c) => Math.round(c.getBoundingClientRect().left)),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 여기까지 왔으면 셀렉트는 렌더된 상태다. 측정이 비었다면 DOM 탐색이 깨진 것이므로
|
||||
// skip 이 아니라 실패로 드러내야 한다 (skip 은 거짓 통과가 된다).
|
||||
expect(measured.length, 'PG 셀렉트를 담은 행을 찾지 못했다 — 측정 경로가 깨졌다').toBeGreaterThan(0);
|
||||
|
||||
for (const row of measured) {
|
||||
const [pgWidth, ...siblingWidths] = row.columnWidths;
|
||||
expect(
|
||||
siblingWidths.length,
|
||||
`${row.id} 행에서 비교할 형제 필드 열을 찾지 못했다 — 측정 경로가 깨졌다`,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// 셀렉트 열이 형제 필드 열보다 좁으면 내용 폭으로 붕괴한 것이다.
|
||||
// (미선택 '---' 은 글자 폭이 가장 작아 회귀가 여기서 가장 크게 드러난다.)
|
||||
const widest = Math.max(...siblingWidths);
|
||||
expect(
|
||||
pgWidth,
|
||||
`${row.id}("${row.label}") 의 PG 열(${pgWidth}px)이 형제 필드 열(${widest}px)보다 좁다 — 셀렉트 폭이 내용 폭을 따라간 회귀`,
|
||||
).toBeGreaterThanOrEqual(widest);
|
||||
}
|
||||
|
||||
// 행마다 PG 열 폭이 다르면 선택값 길이가 레이아웃을 좌우한 것이다
|
||||
// (미선택 행만 좁아지거나, 긴 PG 명을 고른 행만 넓어진다).
|
||||
const pgWidths = new Set(measured.map((r) => r.columnWidths[0]));
|
||||
expect(
|
||||
pgWidths.size,
|
||||
`행마다 PG 열 폭이 다르다 (${[...pgWidths].join(' / ')}px) — 선택값 길이가 열 폭을 좌우하는 회귀`,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
// @scenario extension_payment_method:no_pg_required
|
||||
// @effects admin_shows_pg_select_for_unlocked
|
||||
test('PG 불필요 수단(무통장·포인트 등)은 셀렉트도 배지도 없이 "PG 불필요"로 남는다', async ({
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Modules\Sirsoft\Ecommerce\Http\Resources\Traits\HasMultiCurrencyPrices;
|
||||
use Modules\Sirsoft\Ecommerce\Services\CurrencyConversionService;
|
||||
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
||||
use Modules\Sirsoft\Ecommerce\Support\CurrencySettingsCache;
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* 기본 통화 플래그 단일성 테스트 (#543 — PG 청구 1/100 회귀)
|
||||
*
|
||||
* 저장본에 없는 통화를 defaults 에서 보충할 때(mergeCurrenciesByCode) 보충 항목의
|
||||
* is_default 를 그대로 들여오면 기본 통화가 2개가 된다. 그 상태에서
|
||||
* - buildCurrencySnapshot 이 `is_default ? 1.0 : exchange_rate` 로 환율을 1.0 으로 박제하고
|
||||
* - 표시 계층(HasMultiCurrencyPrices)이 앞선 is_default 통화를 기본으로 오인해
|
||||
* 화면 총액과 PG 청구액이 어긋난다 (base JPY 23,000 → 토스 230 KRW).
|
||||
*
|
||||
* is_default 의 SSoT 는 저장본의 default_currency 값이며, 보충된 통화의 is_default 는 기각한다.
|
||||
*/
|
||||
class EcommerceSettingsCurrencyDefaultFlagTest 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* language_currency.json 을 직접 생성하여 저장된 통화 설정을 시뮬레이션합니다.
|
||||
*/
|
||||
private function saveLanguageCurrency(array $settings): void
|
||||
{
|
||||
File::ensureDirectoryExists($this->storagePath);
|
||||
File::put(
|
||||
$this->storagePath.'/language_currency.json',
|
||||
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 결과에서 is_default 가 true 인 통화 코드 목록을 반환합니다.
|
||||
*/
|
||||
private function defaultCodes(): array
|
||||
{
|
||||
$this->service->clearCache();
|
||||
$lc = $this->service->getSettings('language_currency');
|
||||
|
||||
return collect($lc['currencies'])
|
||||
->filter(fn ($c) => ($c['is_default'] ?? false) === true)
|
||||
->pluck('code')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합 결과를 런타임과 동일하게 config 로 발행합니다.
|
||||
*
|
||||
* 실제 요청에서는 CoreServiceProvider 가 부팅 시 getAllSettings() 결과를
|
||||
* `g7_settings.modules.{id}` 에 넣고, 표시/환산 계층은 그 config 만 읽습니다.
|
||||
* 테스트는 파일을 직접 쓰므로 같은 발행 단계를 재현해야 합니다.
|
||||
*/
|
||||
private function publishSettingsToConfig(): void
|
||||
{
|
||||
$this->service->clearCache();
|
||||
CurrencySettingsCache::clear();
|
||||
|
||||
Config::set(
|
||||
'g7_settings.modules.sirsoft-ecommerce.language_currency',
|
||||
$this->service->getSettings('language_currency')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영 재현 저장본: 기본 통화를 JPY 로 바꾸고 JPY/USD 두 건만 저장한 상태.
|
||||
*/
|
||||
private function saveJpyBaseSubset(): void
|
||||
{
|
||||
$this->saveLanguageCurrency([
|
||||
'default_currency' => 'JPY',
|
||||
'currencies' => [
|
||||
['code' => 'JPY', 'name' => ['ja' => '円'], 'is_default' => true, 'decimal_places' => 0, 'base_unit' => 100, 'exchange_rate' => null],
|
||||
['code' => 'USD', 'name' => ['en' => 'Dollar'], 'is_default' => false, 'decimal_places' => 2, 'base_unit' => 1, 'exchange_rate' => 0.85],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 보충 통화의 is_default 기각
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_backfilled_currency_default_flag_is_rejected(): void
|
||||
{
|
||||
// defaults 의 KRW 는 is_default: true 이며 저장본에 없어 보충 대상이 된다.
|
||||
$this->saveJpyBaseSubset();
|
||||
|
||||
$this->assertSame(['JPY'], $this->defaultCodes(), '보충된 통화의 is_default 가 기각되지 않아 기본 통화가 복수입니다.');
|
||||
}
|
||||
|
||||
public function test_default_currency_is_unique_across_all_saved_shapes(): void
|
||||
{
|
||||
$this->saveJpyBaseSubset();
|
||||
|
||||
$this->service->clearCache();
|
||||
$lc = $this->service->getSettings('language_currency');
|
||||
|
||||
$flags = collect($lc['currencies'])->map(fn ($c) => (bool) ($c['is_default'] ?? false));
|
||||
|
||||
$this->assertSame(1, $flags->filter()->count(), '기본 통화 플래그는 정확히 1개여야 합니다.');
|
||||
$this->assertSame('JPY', $lc['default_currency']);
|
||||
}
|
||||
|
||||
public function test_default_currency_absent_from_saved_list_is_promoted_when_backfilled(): void
|
||||
{
|
||||
// 기본 통화(CNY)가 저장본 currencies 에는 없어 보충 대상이 되는 경우 —
|
||||
// 보충 항목의 플래그를 기각만 하면 기본 통화가 0개가 된다.
|
||||
$this->saveLanguageCurrency([
|
||||
'default_currency' => 'CNY',
|
||||
'currencies' => [
|
||||
['code' => 'USD', 'name' => ['en' => 'Dollar'], 'is_default' => false, 'decimal_places' => 2, 'base_unit' => 1, 'exchange_rate' => 0.85],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertSame(['CNY'], $this->defaultCodes(), '보충된 기본 통화가 승격되지 않아 기본 통화가 사라졌습니다.');
|
||||
}
|
||||
|
||||
public function test_nothing_saved_keeps_defaults_base_currency(): void
|
||||
{
|
||||
// 저장본 없음(신규 설치) → defaults 의 default_currency(KRW) 유지
|
||||
$this->assertSame(['KRW'], $this->defaultCodes());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 기본 통화 해석 SSoT (표시 계층 ↔ 환산 계층 일치)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
public function test_display_layer_resolves_same_base_currency_as_conversion_layer(): void
|
||||
{
|
||||
$this->saveJpyBaseSubset();
|
||||
$this->publishSettingsToConfig();
|
||||
|
||||
$resource = new class
|
||||
{
|
||||
use HasMultiCurrencyPrices {
|
||||
getDefaultCurrencyCode as public exposedDefaultCurrencyCode;
|
||||
getDefaultBaseUnit as public exposedDefaultBaseUnit;
|
||||
}
|
||||
};
|
||||
|
||||
$resource::clearCurrencySettingsCache();
|
||||
$conversion = new CurrencyConversionService;
|
||||
|
||||
$this->assertSame('JPY', $resource->exposedDefaultCurrencyCode(), '표시 계층이 기본 통화를 오인했습니다.');
|
||||
$this->assertSame('JPY', $conversion->getDefaultCurrency());
|
||||
$this->assertSame(100, $resource->exposedDefaultBaseUnit(), '표시 계층의 환산 분모가 기본 통화 base_unit 과 다릅니다.');
|
||||
$this->assertSame(100, $conversion->getDefaultBaseUnit());
|
||||
}
|
||||
|
||||
public function test_unconfigured_exchange_rate_currency_is_not_treated_as_base(): void
|
||||
{
|
||||
// KRW 는 환율 미입력(null) 상태로 보충된다. 기본 통화로 오인되면 환율이 1.0 으로
|
||||
// 박제되어 PG 청구가 base 금액의 1/base_unit 으로 나간다(#543).
|
||||
$this->saveJpyBaseSubset();
|
||||
$this->service->clearCache();
|
||||
|
||||
$lc = $this->service->getSettings('language_currency');
|
||||
$krw = collect($lc['currencies'])->firstWhere('code', 'KRW');
|
||||
|
||||
$this->assertFalse((bool) ($krw['is_default'] ?? false));
|
||||
$this->assertNull($krw['exchange_rate'], '환율 미입력 통화는 null 로 남아 미지원 통화 차단에 걸려야 합니다.');
|
||||
}
|
||||
}
|
||||
+51
@@ -473,6 +473,57 @@ class EcommerceSettingsOrderSettingsTest extends ModuleTestCase
|
||||
$this->assertEquals('tosspayments', $lastMethod['id']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 결제 설정에서는 고아 결제수단이 제거되어야 한다.
|
||||
*
|
||||
* 회귀 배경: 토스페이먼츠 플러그인의 주문서형 결제를 끄면 toss_* 결제수단이
|
||||
* available 카탈로그에서 빠지지만 저장값의 is_active 는 true 로 남는다.
|
||||
* 관리자 화면은 _orphaned 를 읽어 차단하는데 체크아웃은 is_active 만 보므로,
|
||||
* 서버가 걸러주지 않으면 비활성화된 결제수단이 그대로 선택 가능했다.
|
||||
*/
|
||||
public function test_public_payment_settings_exclude_orphaned_methods(): void
|
||||
{
|
||||
$savedSettings = [
|
||||
'payment_methods' => [
|
||||
['id' => 'card', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
|
||||
['id' => 'dbank', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
|
||||
[
|
||||
'id' => 'toss_kakaopay',
|
||||
'sort_order' => 121,
|
||||
'is_active' => true,
|
||||
'min_order_amount' => 0,
|
||||
'stock_deduction_timing' => 'payment_complete',
|
||||
'_cached_name' => ['ko' => '카카오페이 (토스페이먼츠)', 'en' => 'KakaoPay (TossPayments)'],
|
||||
'_cached_icon' => 'wallet',
|
||||
'_cached_source' => 'plugin:sirsoft-tosspayments',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$this->saveOrderSettings($savedSettings);
|
||||
$this->service->clearCache();
|
||||
|
||||
// 플러그인 필터 미등록 → toss_kakaopay 는 available 에 없다(주문서형 결제 OFF 와 동일 상태)
|
||||
$adminMethods = $this->service->getSettings('order_settings')['payment_methods'];
|
||||
$adminToss = collect($adminMethods)->firstWhere('id', 'toss_kakaopay');
|
||||
$this->assertNotNull($adminToss, '관리자 응답에는 고아 항목이 남아 있어야 한다(삭제 UI 필요)');
|
||||
$this->assertTrue($adminToss['_orphaned'] ?? false);
|
||||
|
||||
$publicMethods = $this->service->getPublicPaymentSettings()['payment_methods'];
|
||||
$publicIds = array_column($publicMethods, 'id');
|
||||
|
||||
$this->assertNotContains('toss_kakaopay', $publicIds);
|
||||
$this->assertContains('card', $publicIds);
|
||||
$this->assertContains('dbank', $publicIds);
|
||||
|
||||
// 리스트 인덱스가 재정렬되어야 한다 (JSON 직렬화 시 객체가 되지 않도록)
|
||||
$this->assertSame(range(0, count($publicMethods) - 1), array_keys($publicMethods));
|
||||
|
||||
foreach ($publicMethods as $method) {
|
||||
$this->assertArrayNotHasKey('_orphaned', $method);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// _cached_* 메타데이터 스냅샷
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
(function(){"use strict";function b(t){return new Promise((r,o)=>{if(document.querySelector(`script[src="${t}"]`)){r();return}const e=document.createElement("script");e.src=t,e.async=!0,e.onload=()=>r(),e.onerror=()=>o(new Error(`Failed to load script: ${t}`)),document.head.appendChild(e)})}function C(t,r){if(!t.order_sheet_mode||!r)return{method:"CARD",easyPay:null};const o=(t.enabled_methods??[]).find(e=>e.id===r);return o?{method:o.method,easyPay:o.easy_pay_provider}:{method:"CARD",easyPay:null}}function p(t){if(t==="on")return!0;if(t==="off")return!1}function E(t,r){const o={validHours:t.vbank?.valid_hours??24},e=t.vbank?.cash_receipt_type??"";e&&(o.cashReceipt={type:e});const s=p(t.use_escrow);return s!==void 0&&(o.useEscrow=s),o}function w(t,r,o){if(r.use_escrow==="off")return;const e=o.escrow_products??[];e.length>0&&(t.escrowProducts=e)}async function A(t,r){const o=t.params||{},{pgPaymentData:e}=o;if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const s=window.G7Core,l=o.paymentMethod??s?.state?.getLocal?.()?.paymentMethod;try{const n=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!n.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",n);return}const a=n.data;if(window.TossPayments||await b(a.sdk_url),window.TossPayments||await new Promise(u=>setTimeout(u,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const R=window.TossPayments(a.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),f=e.currency??"KRW",{method:d,easyPay:y}=C(a,l);if(f!=="KRW"&&(d!=="CARD"||y!==null)){console.warn("[sirsoft-tosspayments] non-KRW currency supports card only",{currency:f,method:d}),s?.state?.setLocal?.({paymentErrorMessage:s?.t?.("sirsoft-tosspayments.errors.non_krw_method")??"This payment method is available for KRW only.",isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal");return}const v=window.location.origin,c={method:d,amount:{currency:f,value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:v+a.callback_urls.success,failUrl:v+a.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0};if(d==="CARD")c.card={flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1},y&&(c.easyPay={provider:y});else if(d==="VIRTUAL_ACCOUNT")c.virtualAccount=E(a,e),w(c,a,e);else if(d==="TRANSFER"){const u=p(a.use_escrow);u!==void 0&&(c.transfer={useEscrow:u}),w(c,a,e)}await R.requestPayment(c)}catch(n){if(console.error("[sirsoft-tosspayments] requestPayment error",n),n?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await s.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:n.code,cancel_message:n.message})}catch(P){console.warn("[sirsoft-tosspayments] Failed to record cancellation",P)}s?.state?.setLocal?.({isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_cancel_modal");return}const a=n?.message??"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal")}}const _={requestPayment:A},i="sirsoft-tosspayments",m={info:(...t)=>console.info(`[${i}]`,...t),warn:(...t)=>console.warn(`[${i}]`,...t),error:(...t)=>console.error(`[${i}]`,...t)};function h(){const t=window.G7Core;if(!t)return 0;const r=t.getActionDispatcher;if(typeof r!="function")return 0;const o=r();if(!o||typeof o.registerHandler!="function")return 0;let e=0;for(const[s,l]of Object.entries(_)){const n=`${i}.${s}`;o.registerHandler(n,l,{category:"plugin",source:i}),e++}return e}function g(){const t=()=>{const r=h();if(r>0){m.info(`${r} handler(s) registered`);return}let o=0;const e=50,s=setInterval(()=>{o++;const l=h();if(l>0){clearInterval(s),m.info(`${l} handler(s) registered (after ${o} retries)`);return}o>=e&&(clearInterval(s),m.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}g(),window.__SirsoftTosspayments={identifier:i,handlers:Object.keys(_),initPlugin:g}})();
|
||||
(function(){"use strict";function b(t){return new Promise((r,o)=>{if(document.querySelector(`script[src="${t}"]`)){r();return}const e=document.createElement("script");e.src=t,e.async=!0,e.onload=()=>r(),e.onerror=()=>o(new Error(`Failed to load script: ${t}`)),document.head.appendChild(e)})}function C(t,r){if(!t.order_sheet_mode||!r)return{method:"CARD",easyPay:null};const o=(t.enabled_methods??[]).find(e=>e.id===r);return o?{method:o.method,easyPay:o.easy_pay_provider}:{method:"CARD",easyPay:null}}function p(t){if(t==="on")return!0;if(t==="off")return!1}function E(t,r){const o={validHours:t.vbank?.valid_hours??24},e=t.vbank?.cash_receipt_type??"";e&&(o.cashReceipt={type:e});const s=p(t.use_escrow);return s!==void 0&&(o.useEscrow=s),o}function w(t,r,o){if(r.use_escrow==="off")return;const e=o.escrow_products??[];e.length>0&&(t.escrowProducts=e)}async function R(t,r){const o=t.params||{},{pgPaymentData:e}=o;if(!e){console.error("[sirsoft-tosspayments] pgPaymentData is required");return}const s=window.G7Core,l=o.paymentMethod??s?.state?.getLocal?.()?.paymentMethod;try{const n=await s.api.get("/modules/sirsoft-ecommerce/payments/client-config/tosspayments");if(!n.data){console.error("[sirsoft-tosspayments] Failed to fetch client config",n);return}const a=n.data;if(window.TossPayments||await b(a.sdk_url),window.TossPayments||await new Promise(u=>setTimeout(u,100)),!window.TossPayments){console.error("[sirsoft-tosspayments] TossPayments SDK not available");return}const T=window.TossPayments(a.client_key).payment({customerKey:e.customer_key??window.TossPayments.ANONYMOUS}),f=e.currency??"KRW",{method:d,easyPay:y}=C(a,l);if(f!=="KRW"&&(d!=="CARD"||y!==null)){console.warn("[sirsoft-tosspayments] non-KRW currency supports card only",{currency:f,method:d}),s?.state?.setLocal?.({paymentErrorMessage:s?.t?.("sirsoft-tosspayments.errors.non_krw_method")??"This payment method is available for KRW only.",isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal");return}const v=window.location.origin,c={method:d,amount:{currency:f,value:e.amount},orderId:e.order_number,orderName:e.order_name,successUrl:v+a.callback_urls.success,failUrl:v+a.callback_urls.fail,customerEmail:e.customer_email??void 0,customerName:e.customer_name??void 0,customerMobilePhone:e.customer_phone??void 0};if(d==="CARD")c.card={flowMode:"DEFAULT",useCardPoint:!1,useAppCardOnly:!1},y&&(c.card.flowMode="DIRECT",c.card.easyPay=y);else if(d==="VIRTUAL_ACCOUNT")c.virtualAccount=E(a,e),w(c,a,e);else if(d==="TRANSFER"){const u=p(a.use_escrow);u!==void 0&&(c.transfer={useEscrow:u}),w(c,a,e)}await T.requestPayment(c)}catch(n){if(console.error("[sirsoft-tosspayments] requestPayment error",n),n?.code==="USER_CANCEL"){console.info("[sirsoft-tosspayments] Payment cancelled by user");try{await s.api.post(`/modules/sirsoft-ecommerce/orders/${e.order_number}/cancel-payment`,{cancel_code:n.code,cancel_message:n.message})}catch(P){console.warn("[sirsoft-tosspayments] Failed to record cancellation",P)}s?.state?.setLocal?.({isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_cancel_modal");return}const a=n?.message??"Unknown error";s?.state?.setLocal?.({paymentErrorMessage:a,isSubmittingOrder:!1}),s?.modal?.open?.("tosspayments_payment_error_modal")}}const _={requestPayment:R},i="sirsoft-tosspayments",m={info:(...t)=>console.info(`[${i}]`,...t),warn:(...t)=>console.warn(`[${i}]`,...t),error:(...t)=>console.error(`[${i}]`,...t)};function h(){const t=window.G7Core;if(!t)return 0;const r=t.getActionDispatcher;if(typeof r!="function")return 0;const o=r();if(!o||typeof o.registerHandler!="function")return 0;let e=0;for(const[s,l]of Object.entries(_)){const n=`${i}.${s}`;o.registerHandler(n,l,{category:"plugin",source:i}),e++}return e}function g(){const t=()=>{const r=h();if(r>0){m.info(`${r} handler(s) registered`);return}let o=0;const e=50,s=setInterval(()=>{o++;const l=h();if(l>0){clearInterval(s),m.info(`${l} handler(s) registered (after ${o} retries)`);return}o>=e&&(clearInterval(s),m.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",t):t()}g(),window.__SirsoftTosspayments={identifier:i,handlers:Object.keys(_),initPlugin:g}})();
|
||||
//# sourceMappingURL=plugin.iife.js.map
|
||||
|
||||
+17
-2
@@ -261,13 +261,28 @@ describe('requestPaymentHandler', () => {
|
||||
expect(capturedPayload.virtualAccount.cashReceipt).toEqual({ type: '소득공제' });
|
||||
});
|
||||
|
||||
it('간편결제 선택 시 CARD + easyPay provider', async () => {
|
||||
it('간편결제 선택 시 CARD + card.easyPay 자체창 직행 (flowMode DIRECT)', async () => {
|
||||
setupSdk(baseConfig());
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_kakaopay' } });
|
||||
|
||||
// 토스 v2 결제창 계약: 간편결제 자체창은 card 객체 안의 easyPay + flowMode DIRECT.
|
||||
// top-level easyPay 키는 SDK 가 읽지 않아 통합결제창이 열린다 (회귀 pin).
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.easyPay).toEqual({ provider: '카카오페이' });
|
||||
expect(capturedPayload.card.flowMode).toBe('DIRECT');
|
||||
expect(capturedPayload.card.easyPay).toBe('카카오페이');
|
||||
expect(capturedPayload.easyPay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('일반 카드 선택 시 flowMode DEFAULT + easyPay 부재 (통합결제창)', async () => {
|
||||
setupSdk(baseConfig());
|
||||
|
||||
await requestPaymentHandler({ params: { pgPaymentData: pgData(), paymentMethod: 'toss_card' } });
|
||||
|
||||
expect(capturedPayload.method).toBe('CARD');
|
||||
expect(capturedPayload.card.flowMode).toBe('DEFAULT');
|
||||
expect('easyPay' in capturedPayload.card).toBe(false);
|
||||
expect(capturedPayload.easyPay).toBeUndefined();
|
||||
});
|
||||
|
||||
it('에스크로 on 이면 가상계좌 useEscrow=true', async () => {
|
||||
|
||||
@@ -275,9 +275,12 @@ export async function requestPaymentHandler(action: any, _context?: any): Promis
|
||||
useCardPoint: false,
|
||||
useAppCardOnly: false,
|
||||
};
|
||||
// 간편결제(easyPay)는 CARD method 에 easyPay provider 를 실어 호출한다.
|
||||
// 간편결제는 card 객체 안에 easyPay(간편결제사 코드) + flowMode DIRECT 로
|
||||
// 자체창을 직행 호출한다 (토스 v2 결제창 계약 — 카드사/간편결제 자체창 연동).
|
||||
// top-level easyPay 키는 SDK 파라미터가 아니라서 무시되고 통합결제창이 열린다.
|
||||
if (easyPay) {
|
||||
requestPayload.easyPay = { provider: easyPay };
|
||||
requestPayload.card.flowMode = 'DIRECT';
|
||||
requestPayload.card.easyPay = easyPay;
|
||||
}
|
||||
} else if (method === 'VIRTUAL_ACCOUNT') {
|
||||
// 가상계좌 — 에스크로 적용 대상 (escrowProducts 는 에스크로 사용 시 필수)
|
||||
|
||||
+4
-4
File diff suppressed because one or more lines are too long
+75
-32
File diff suppressed because one or more lines are too long
@@ -4222,6 +4222,41 @@ export class ActionDispatcher {
|
||||
const update = this.createNestedUpdate(path, value, currentState);
|
||||
const mergedState = { ...currentState, ...update };
|
||||
context.setState(update);
|
||||
|
||||
// engine-v1.58.2: dot notation 경로도 canonical source(_global._local)에 동기화한다.
|
||||
//
|
||||
// 배경: 이 분기는 context.setState(저장소 A: React localDynamicState)만 갱신하고
|
||||
// _global._local(저장소 B)은 갱신하지 않았다. 커스텀 핸들러(모듈/플러그인)가 상태를
|
||||
// 읽는 유일한 공개 통로인 G7Core.state.getLocal() 은 B 를 읽으므로, dot notation 으로
|
||||
// 기록된 값은 핸들러에게 undefined 로 보였다 (체크아웃 간편결제 선택 → PG 플러그인이
|
||||
// 선택값을 못 읽어 통합결제창이 열린 결함). target: "local" 형태는 engine-v1.50.0 에서
|
||||
// 이미 B 를 동기화하므로, 남아 있던 비대칭을 해소한다.
|
||||
//
|
||||
// [동기화 기준을 전체 스냅샷이 아니라 "live B + 변경 키"로 잡은 이유]
|
||||
// COMPONENT path(target:"local")는 currentState(=pending ?? context.state) 전체 스냅샷을
|
||||
// B 에 통째로 넘긴다. context.state 는 클릭된 리프 컴포넌트의 부분 상태일 수 있어
|
||||
// B 의 다른 키를 잃을 수 있고(engine-v1.50.0 주석의 안전성 의존 관계), 앞선
|
||||
// 커스텀 핸들러 setLocal 결과를 stale base 로 되돌릴 수 있다(트러블슈팅 사례 24).
|
||||
// 여기서는 live B 를 base 로 삼고 변경된 최상위 키만 얹으므로 두 위험이 모두 없다.
|
||||
// B 를 못 읽는 환경(테스트 등 __templateApp 부재)에서만 전체 스냅샷으로 폴백한다.
|
||||
//
|
||||
// [__g7PendingLocalState 를 쓰지 않는 이유]
|
||||
// pending 에 B 기반 전체 스냅샷을 넣으면 handleLocalSetState 의 effectivePrev 병합에서
|
||||
// React 전용 상태(DataGrid expandedRows 등)를 초기값으로 덮어쓴다(트러블슈팅 사례 22).
|
||||
// setGlobalState 는 globalState 를 동기 대입하므로 pending 없이도 같은 tick 의
|
||||
// getLocal() 이 즉시 최신값을 읽는다 — 오염 경로에 진입할 이유가 없다.
|
||||
//
|
||||
// scope: 'parent' | 'root' 은 이 분기가 구현하지 않는 타깃이므로 동기화 대상에서 제외한다
|
||||
// (모달에서 부모 스코프를 노린 setState 가 페이지 저장소를 오염시키는 것 방지 — 사례 29).
|
||||
if (this.globalStateUpdater && scope !== 'parent' && scope !== 'root') {
|
||||
const canonicalLocal = (window as any).__templateApp?.getGlobalState?.()?._local;
|
||||
const syncedLocal = canonicalLocal && typeof canonicalLocal === 'object'
|
||||
? { ...canonicalLocal, ...update }
|
||||
: mergedState;
|
||||
this.globalStateUpdater({ _local: syncedLocal }, { render: false });
|
||||
logger.log('[handleSetState] _global._local synced for dot notation (render:false):', syncedLocal);
|
||||
}
|
||||
|
||||
if (setStateId && devTools) setTimeout(() => devTools.completeStateChange(setStateId), 0);
|
||||
return mergedState;
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,20 @@
|
||||
>
|
||||
> 형식: [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)
|
||||
|
||||
## [engine-v1.58.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
|
||||
#### `target: "_local.xxx"` 로 기록한 값을 커스텀 핸들러가 읽지 못하던 문제
|
||||
|
||||
- `ActionDispatcher.ts::handleSetState` — dot notation 경로(`target: "_local.paymentMethod"`)로 기록한 값을 canonical source 인 `_global._local` 에도 동기화한다. 기존에는 `context.setState`(React `localDynamicState`)만 갱신하고 `_global._local` 은 갱신하지 않았다. 모듈·플러그인 커스텀 핸들러가 상태를 읽는 공개 통로인 `G7Core.state.getLocal()` 은 `_global._local` 을 읽으므로, dot notation 으로 기록된 값은 핸들러에게 `undefined` 로 보였다.
|
||||
- 화면은 정상으로 보인다 — 선택 표시는 React 저장소만으로 그려지기 때문이다. **예외도 경고도 콘솔 출력도 없이 핸들러가 받는 값만 비어** 발견이 늦다. 실제 발현: 주문서형 결제에서 간편결제(네이버페이 등)를 고르고 결제하면 PG 플러그인이 선택값을 못 읽어 간편결제 자체창 대신 통합결제창이 열렸다.
|
||||
- `target: "local"` 형태는 engine-v1.50.0 에서 이미 `_global._local` 을 동기화하고 있었다. 같은 뜻의 두 표기가 서로 다른 저장소에 쓰이던 비대칭을 해소한 것이다.
|
||||
- 동기화 기준은 **현재 `_global._local` + 변경된 최상위 키**다. `target: "local"` 경로처럼 전체 스냅샷을 넘기지 않는다 — 클릭된 리프 컴포넌트의 부분 상태가 base 가 되면 `_global._local` 의 다른 키를 잃거나, 앞서 커스텀 핸들러가 `setLocal()` 로 기록한 값을 되돌릴 수 있다.
|
||||
- `__g7PendingLocalState` 는 갱신하지 않는다. `_global._local` 기반 전체 스냅샷을 pending 에 넣으면 DataGrid `expandedRows` 처럼 React 에만 존재하는 상태를 초기값으로 덮어쓴다. 전역 상태 대입은 동기적이라 pending 없이도 같은 tick 의 `getLocal()` 이 최신값을 읽는다.
|
||||
- `scope: "parent" | "root"` 는 dot notation 분기가 구현하지 않는 타깃이므로 동기화 대상에서 제외한다(모달에서 부모 스코프를 노린 `setState` 가 페이지 저장소를 오염시키는 것 방지).
|
||||
- 저장소 영향 범위(전수 확인): `target: "_local.xxx"` 사용은 8건이며 전부 `sirsoft-basic` 체크아웃 3개 파일이다(결제수단 선택·무통장 은행 선택·환불계좌 초기화·주문 제출 플래그). TS/TSX 코드에서 이 형태로 dispatch 하는 곳과 `$parent._local.xxx` / `$root._local.xxx` 사용은 0건이다.
|
||||
|
||||
## [engine-v1.58.1] - 2026-08-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -5151,3 +5151,121 @@ describe('[사례 38] 액션을 event 로 적은 경우의 prop 이름과 $event
|
||||
expect(captured?.form?.content).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* [사례 39] setState `target: "_local.xxx"` 가 canonical source(_global._local)에 반영되지 않음
|
||||
*
|
||||
* 배경: 체크아웃에서 간편결제(네이버페이)를 고르고 결제하면 자체창이 아니라 통합결제창이 열렸다.
|
||||
* 결제수단 선택은 `{"target": "_local.paymentMethod"}` 로 기록되는데, dot notation 분기는
|
||||
* `context.setState`(저장소 A: React localDynamicState)만 갱신하고 `_global._local`(저장소 B)은
|
||||
* 갱신하지 않았다. PG 플러그인 핸들러는 `G7Core.state.getLocal()`(저장소 B 를 읽음)로 선택값을
|
||||
* 조회하므로 undefined 를 받았고, 간편결제 지정이 통째로 누락된 채 통합결제창이 열렸다.
|
||||
*
|
||||
* `target: "local"` 형태는 engine-v1.50.0 에서 이미 B 를 동기화하고 있었다 — dot notation 만
|
||||
* 남겨진 비대칭이었다.
|
||||
*
|
||||
* @see 트러블슈팅 가이드 "setState & init_actions & dataKey" 사례 39
|
||||
*/
|
||||
describe('[사례 39] dot notation setState 의 canonical source 동기화 (engine-v1.58.2)', () => {
|
||||
let dispatcher: ActionDispatcher;
|
||||
let globalState: Record<string, any>;
|
||||
let componentState: Record<string, any>;
|
||||
let setStateCalls: any[];
|
||||
|
||||
/** TemplateApp.setGlobalState 와 동일한 계약: 최상위 키 얕은 병합 + 동기 대입 */
|
||||
const globalStateUpdater = vi.fn((updates: any) => {
|
||||
globalState = { ...globalState, ...updates };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setStateCalls = [];
|
||||
componentState = {};
|
||||
globalState = { _local: {} };
|
||||
|
||||
dispatcher = new ActionDispatcher({ navigate: vi.fn() });
|
||||
dispatcher.setGlobalStateUpdater(globalStateUpdater);
|
||||
Logger.getInstance().setDebug(false);
|
||||
|
||||
(window as any).__templateApp = { getGlobalState: () => globalState };
|
||||
(window as any).__g7PendingLocalState = undefined;
|
||||
(window as any).__g7ForcedLocalFields = undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete (window as any).__templateApp;
|
||||
(window as any).__g7PendingLocalState = undefined;
|
||||
(window as any).__g7ForcedLocalFields = undefined;
|
||||
});
|
||||
|
||||
const context = () => ({
|
||||
state: componentState,
|
||||
setState: vi.fn((updates: any) => {
|
||||
setStateCalls.push(updates);
|
||||
componentState = { ...componentState, ...updates };
|
||||
}),
|
||||
actionId: 'case-39',
|
||||
});
|
||||
|
||||
it('결함 재현: 선택값이 _global._local 에 반영되어 getLocal() 경로로 읽힌다', async () => {
|
||||
await dispatcher.executeAction(
|
||||
{ handler: 'setState' as const, params: { target: '_local.paymentMethod', value: 'toss_naverpay' } },
|
||||
context()
|
||||
);
|
||||
|
||||
expect(globalState._local.paymentMethod).toBe('toss_naverpay');
|
||||
});
|
||||
|
||||
it('사례 13·24 핀: canonical source 의 기존 키를 하나도 잃지 않는다 (leaf partial context 에서도)', async () => {
|
||||
// 커스텀 핸들러의 setLocal 등으로 이미 canonical store 에 쌓여 있는 값
|
||||
globalState._local = { orderer: { name: '관리자' }, shipping: { zipcode: '94271' }, couponId: 7 };
|
||||
// 클릭된 리프 컴포넌트의 context.state 는 페이지 _local 전체가 아니다
|
||||
componentState = { paymentMethod: 'toss_card' };
|
||||
|
||||
await dispatcher.executeAction(
|
||||
{ handler: 'setState' as const, params: { target: '_local.paymentMethod', value: 'toss_naverpay' } },
|
||||
context()
|
||||
);
|
||||
|
||||
expect(globalState._local.paymentMethod).toBe('toss_naverpay');
|
||||
expect(globalState._local.orderer).toEqual({ name: '관리자' });
|
||||
expect(globalState._local.shipping).toEqual({ zipcode: '94271' });
|
||||
expect(globalState._local.couponId).toBe(7);
|
||||
});
|
||||
|
||||
it('사례 22 핀: __g7PendingLocalState 를 건드리지 않는다 (React 전용 배열 오염 경로 차단)', async () => {
|
||||
(window as any).__g7PendingLocalState = { expandedRows: [301] };
|
||||
|
||||
await dispatcher.executeAction(
|
||||
{ handler: 'setState' as const, params: { target: '_local.paymentMethod', value: 'toss_naverpay' } },
|
||||
context()
|
||||
);
|
||||
|
||||
expect((window as any).__g7PendingLocalState).toEqual({ expandedRows: [301] });
|
||||
});
|
||||
|
||||
it('사례 29 핀: scope parent/root 는 동기화 대상이 아니다 (모달 컨텍스트 오염 방지)', async () => {
|
||||
await dispatcher.executeAction(
|
||||
{
|
||||
handler: 'setState' as const,
|
||||
params: { target: '_local.paymentMethod', scope: 'parent', value: 'toss_naverpay' },
|
||||
},
|
||||
context()
|
||||
);
|
||||
|
||||
expect(globalState._local.paymentMethod).toBeUndefined();
|
||||
});
|
||||
|
||||
it('기존 동작 유지: context.setState 에는 여전히 중첩 경로 변경분만 전달된다', async () => {
|
||||
componentState = { form: { title: '기존', body: '유지' } };
|
||||
|
||||
await dispatcher.executeAction(
|
||||
{ handler: 'setState' as const, params: { target: '_local.form.title', value: '변경' } },
|
||||
context()
|
||||
);
|
||||
|
||||
expect(setStateCalls).toHaveLength(1);
|
||||
expect(setStateCalls[0]).toEqual({ form: { title: '변경', body: '유지' } });
|
||||
expect(globalState._local.form).toEqual({ title: '변경', body: '유지' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,59 @@ function scanExtensions($path) {
|
||||
return array_values($extensions);
|
||||
}
|
||||
|
||||
/**
|
||||
* 디렉토리 하위 항목을 삭제합니다 (디렉토리 자체는 보존).
|
||||
*
|
||||
* 인스톨러 초기화에서 "설정/업로드 내용물만 비우고 디렉토리 구조는 남긴다" 는
|
||||
* 용도로 사용합니다. 디렉토리 자체를 지우면 권한/소유권이 재생성 시점의 실행
|
||||
* 계정으로 바뀌므로 컨테이너를 보존합니다.
|
||||
*
|
||||
* @param string $dir 대상 디렉토리 절대경로
|
||||
* @param array $keep 보존할 항목명 목록 (basename 기준, 예: ['settings', '.preserve-ownership'])
|
||||
* @return array{deleted: int, failed: int, names: array} 삭제 건수 / 실패 건수 / 삭제된 항목명
|
||||
*/
|
||||
function devDashboardClearDirectory(string $dir, array $keep = []): array
|
||||
{
|
||||
$result = ['deleted' => 0, 'failed' => 0, 'names' => []];
|
||||
|
||||
if (!is_dir($dir)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$isWindows = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||
$keep = array_merge(['.', '..', '.gitignore', '.gitkeep'], $keep);
|
||||
|
||||
foreach (scandir($dir) as $item) {
|
||||
if (in_array($item, $keep, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
||||
|
||||
if (is_dir($path)) {
|
||||
$escaped = escapeshellarg($path);
|
||||
if ($isWindows) {
|
||||
exec("rmdir /s /q $escaped 2>nul");
|
||||
} else {
|
||||
exec("rm -rf $escaped");
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$result['failed']++;
|
||||
} else {
|
||||
$result['deleted']++;
|
||||
$result['names'][] = $item;
|
||||
}
|
||||
} elseif (@unlink($path)) {
|
||||
$result['deleted']++;
|
||||
$result['names'][] = $item;
|
||||
} else {
|
||||
$result['failed']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$modules = scanExtensions(base_path('modules'));
|
||||
$templates = scanExtensions(base_path('templates'));
|
||||
$plugins = scanExtensions(base_path('plugins'));
|
||||
@@ -366,6 +419,110 @@ if (isset($_GET['ajax_action'])) {
|
||||
}
|
||||
}
|
||||
|
||||
// 환경설정 파일 삭제 (코어 + 모듈 + 플러그인 + 템플릿)
|
||||
$deleteSettings = isset($_GET['delete_settings']) && $_GET['delete_settings'] === '1';
|
||||
if ($deleteSettings) {
|
||||
// 코어: storage/app/settings/*.json (SettingsService 의 SSoT)
|
||||
$coreSettingsPath = storage_path('app/settings');
|
||||
$cleared = devDashboardClearDirectory($coreSettingsPath);
|
||||
if ($cleared['deleted'] === 0 && $cleared['failed'] === 0) {
|
||||
$results[] = ['name' => '코어 환경설정 (storage/app/settings)', 'status' => 'notfound'];
|
||||
} else {
|
||||
$results[] = [
|
||||
'name' => "코어 환경설정 (storage/app/settings, {$cleared['deleted']}개)",
|
||||
'status' => $cleared['failed'] === 0 ? 'deleted' : 'failed'
|
||||
];
|
||||
}
|
||||
|
||||
// 모듈/플러그인: storage/app/{modules,plugins}/{identifier}/settings/
|
||||
// (ModuleSettingsService / PluginSettingsService 가 StorageInterface 로 기록하는 경로)
|
||||
foreach (['modules' => '모듈', 'plugins' => '플러그인'] as $storageDir => $label) {
|
||||
$basePath = storage_path('app/' . $storageDir);
|
||||
$clearedIds = [];
|
||||
$failedIds = [];
|
||||
if (is_dir($basePath)) {
|
||||
foreach (array_filter(glob($basePath . '/*'), 'is_dir') as $extDir) {
|
||||
$settingsDir = $extDir . DIRECTORY_SEPARATOR . 'settings';
|
||||
if (!is_dir($settingsDir)) {
|
||||
continue;
|
||||
}
|
||||
$cleared = devDashboardClearDirectory($settingsDir);
|
||||
if ($cleared['failed'] > 0) {
|
||||
$failedIds[] = basename($extDir);
|
||||
} elseif ($cleared['deleted'] > 0) {
|
||||
$clearedIds[] = basename($extDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($clearedIds) && empty($failedIds)) {
|
||||
$results[] = ['name' => "{$label} 환경설정 (storage/app/{$storageDir})", 'status' => 'notfound'];
|
||||
} else {
|
||||
$detail = implode(', ', array_merge($clearedIds, $failedIds));
|
||||
$results[] = [
|
||||
'name' => "{$label} 환경설정 ({$detail})",
|
||||
'status' => empty($failedIds) ? 'deleted' : 'failed'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿은 파일 기반 환경설정 저장소를 갖지 않는다.
|
||||
// 활성 상태/레이아웃 편집분은 DB(templates, template_layouts), 나머지는 템플릿 패키지 파일이므로
|
||||
// 각각 "DB 테이블 전체 삭제" 와 "확장 설치 디렉토리 삭제" 가 담당한다.
|
||||
// 삭제 대상이 0 건인 것과 대상 자체가 없는 것은 다르므로 그 사실을 그대로 표기한다.
|
||||
$results[] = [
|
||||
'name' => '템플릿 환경설정 (파일 저장소 없음 — DB/패키지 보관)',
|
||||
'status' => 'notfound'
|
||||
];
|
||||
}
|
||||
|
||||
// 첨부파일 스토리지 삭제
|
||||
$deleteAttachments = isset($_GET['delete_attachments']) && $_GET['delete_attachments'] === '1';
|
||||
if ($deleteAttachments) {
|
||||
// 코어 첨부파일 디스크(config/attachment.php) + 공개 업로드 디스크
|
||||
$attachmentPaths = [
|
||||
'storage/app/attachments' => storage_path('app/attachments'),
|
||||
'storage/app/public' => storage_path('app/public')
|
||||
];
|
||||
foreach ($attachmentPaths as $displayName => $path) {
|
||||
$cleared = devDashboardClearDirectory($path);
|
||||
if ($cleared['deleted'] === 0 && $cleared['failed'] === 0) {
|
||||
$results[] = ['name' => $displayName, 'status' => 'notfound'];
|
||||
} else {
|
||||
$results[] = [
|
||||
'name' => "{$displayName} ({$cleared['deleted']}개)",
|
||||
'status' => $cleared['failed'] === 0 ? 'deleted' : 'failed'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 모듈/플러그인 업로드 파일 (예: storage/app/modules/{id}/images/products)
|
||||
// settings/ 와 .preserve-ownership 마커는 보존 — 환경설정 삭제 옵션과 서로 간섭하지 않도록 분리한다.
|
||||
foreach (['modules' => '모듈', 'plugins' => '플러그인'] as $storageDir => $label) {
|
||||
$basePath = storage_path('app/' . $storageDir);
|
||||
$clearedIds = [];
|
||||
$failedIds = [];
|
||||
if (is_dir($basePath)) {
|
||||
foreach (array_filter(glob($basePath . '/*'), 'is_dir') as $extDir) {
|
||||
$cleared = devDashboardClearDirectory($extDir, ['settings', '.preserve-ownership']);
|
||||
if ($cleared['failed'] > 0) {
|
||||
$failedIds[] = basename($extDir);
|
||||
} elseif ($cleared['deleted'] > 0) {
|
||||
$clearedIds[] = basename($extDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (empty($clearedIds) && empty($failedIds)) {
|
||||
$results[] = ['name' => "{$label} 업로드 파일 (storage/app/{$storageDir})", 'status' => 'notfound'];
|
||||
} else {
|
||||
$detail = implode(', ', array_merge($clearedIds, $failedIds));
|
||||
$results[] = [
|
||||
'name' => "{$label} 업로드 파일 ({$detail})",
|
||||
'status' => empty($failedIds) ? 'deleted' : 'failed'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'results' => $results]);
|
||||
exit;
|
||||
}
|
||||
@@ -1002,6 +1159,24 @@ if (isset($_GET['ajax_action'])) {
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||||
<span>환경설정 파일 삭제</span>
|
||||
<span class="text-[10px] text-slate-500">(코어, 모듈, 플러그인)</span>
|
||||
<label class="relative cursor-pointer ml-auto">
|
||||
<input type="checkbox" id="deleteSettings" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs text-slate-400 bg-slate-900/30 px-3 py-2 rounded-lg">
|
||||
<span>첨부파일 스토리지 삭제</span>
|
||||
<span class="text-[10px] text-slate-500">(업로드 파일 전체)</span>
|
||||
<label class="relative cursor-pointer ml-auto">
|
||||
<input type="checkbox" id="deleteAttachments" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-700 rounded-full peer peer-checked:bg-red-600 transition-colors"></div>
|
||||
<div class="absolute left-0.5 top-0.5 w-4 h-4 bg-white rounded-full transition-transform peer-checked:translate-x-4"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1239,6 +1414,8 @@ if (isset($_GET['ajax_action'])) {
|
||||
const deleteVendor = document.getElementById('deleteVendor').checked;
|
||||
const deleteExtensions = document.getElementById('deleteExtensions').checked;
|
||||
const dropTables = document.getElementById('dropTables').checked;
|
||||
const deleteSettings = document.getElementById('deleteSettings').checked;
|
||||
const deleteAttachments = document.getElementById('deleteAttachments').checked;
|
||||
let message = '⚠️ 인스톨러 초기화\n\n';
|
||||
message += '다음 항목이 삭제됩니다:\n';
|
||||
message += ' • .env\n';
|
||||
@@ -1255,6 +1432,17 @@ if (isset($_GET['ajax_action'])) {
|
||||
if (dropTables) {
|
||||
message += ' • DB 테이블 전체 삭제 (migrations 포함)\n';
|
||||
}
|
||||
if (deleteSettings) {
|
||||
message += ' • storage/app/settings/ (코어 환경설정)\n';
|
||||
message += ' • storage/app/modules/*/settings/ (모듈 환경설정)\n';
|
||||
message += ' • storage/app/plugins/*/settings/ (플러그인 환경설정)\n';
|
||||
message += ' ※ 템플릿은 파일 환경설정이 없음 (DB/패키지 보관)\n';
|
||||
}
|
||||
if (deleteAttachments) {
|
||||
message += ' • storage/app/attachments/ (첨부파일)\n';
|
||||
message += ' • storage/app/public/ (공개 업로드 파일)\n';
|
||||
message += ' • storage/app/modules|plugins/*/ 업로드 파일 (settings 보존)\n';
|
||||
}
|
||||
message += '\n계속하시겠습니까?';
|
||||
|
||||
if (!confirm(message)) return;
|
||||
@@ -1266,7 +1454,7 @@ if (isset($_GET['ajax_action'])) {
|
||||
panel.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const url = `${window.location.pathname}?ajax_action=reset&delete_vendor=${deleteVendor ? '1' : '0'}&delete_extensions=${deleteExtensions ? '1' : '0'}&drop_tables=${dropTables ? '1' : '0'}`;
|
||||
const url = `${window.location.pathname}?ajax_action=reset&delete_vendor=${deleteVendor ? '1' : '0'}&delete_extensions=${deleteExtensions ? '1' : '0'}&drop_tables=${dropTables ? '1' : '0'}&delete_settings=${deleteSettings ? '1' : '0'}&delete_attachments=${deleteAttachments ? '1' : '0'}`;
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* E2E: `setState target: "_local.xxx"` 로 기록한 값을 커스텀 핸들러가 읽을 수 있는지 (배포 번들 기준)
|
||||
*
|
||||
* 배경: `_local` 은 저장소가 둘이다 — React `localDynamicState`(A)와 `_global._local`(B).
|
||||
* dot notation 분기는 A 만 갱신하고 B 를 갱신하지 않았다. 모듈·플러그인 커스텀 핸들러가 상태를
|
||||
* 읽는 공개 통로인 `G7Core.state.getLocal()` 은 B 를 읽으므로, dot notation 으로 기록된 값은
|
||||
* 핸들러에게 `undefined` 로 보였다.
|
||||
*
|
||||
* 화면은 정상으로 보인다 — 선택 표시는 A 만으로 그려지기 때문이다. 예외도 콘솔 경고도 남지 않아
|
||||
* 화면 확인만으로는 드러나지 않는다. 실제 발현: 주문서형 결제에서 간편결제(네이버페이 등)를
|
||||
* 고르고 결제하면 PG 플러그인이 선택값을 못 읽어 간편결제 자체창 대신 통합결제창이 열렸다.
|
||||
*
|
||||
* 단위 테스트가 소스를 잠그지만, 실제 사용자가 실행하는 것은 빌드된 번들이므로 브라우저에서
|
||||
* 배포 번들의 액션 파이프를 직접 태워 결과를 잠근다. PG 샌드박스는 필요 없다 — 잠그는 계약은
|
||||
* "dot notation 기록이 `getLocal()` 에 도달한다" 하나이고, 화면과 무관하게 성립해야 한다.
|
||||
*
|
||||
* @scenario setstate_dot_path_local_store_sync
|
||||
* @effects dot_path_setstate_reaches_getlocal,
|
||||
* dot_path_setstate_preserves_other_local_keys,
|
||||
* dot_path_setstate_accumulates_across_writes
|
||||
*/
|
||||
import { test, expect } from '../fixtures/auth';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/** 엔진이 부팅된 아무 공개 화면. dot notation 계약은 화면과 무관하게 성립해야 한다. */
|
||||
const ANY_ENGINE_PAGE = '/';
|
||||
|
||||
/** 테스트 전용 키 — 어떤 레이아웃도 쓰지 않는 이름이라 기존 상태와 충돌하지 않는다. */
|
||||
const PROBE_KEY = '__e2eDotPathProbe';
|
||||
|
||||
/**
|
||||
* 템플릿 렌더가 끝날 때까지 기다린다.
|
||||
*
|
||||
* `G7Core.dispatch` 는 TemplateApp 부팅 **중간**에 이미 존재하지만, 액션 디스패처에
|
||||
* `setGlobalState` 가 주입되는 것은 그보다 뒤다. dispatch 가능 시점만 보고 진행하면
|
||||
* 아직 주입 전이라 canonical 저장소 동기화가 통째로 건너뛰어진다(실측으로 이 함정에 빠졌다).
|
||||
* 렌더 완료를 게이트로 삼아 부팅이 끝난 뒤에 계약을 검증한다.
|
||||
*/
|
||||
async function waitForEngineBoot(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => typeof (window as any).G7Core?.dispatch === 'function');
|
||||
await page.locator('footer').first().waitFor({ state: 'attached', timeout: 30_000 });
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
test.describe('setState dot notation 경로의 _local 저장소 동기화', () => {
|
||||
test('dot notation 으로 기록한 값이 getLocal() 로 읽힌다', async ({ page }) => {
|
||||
await page.goto(ANY_ENGINE_PAGE);
|
||||
await waitForEngineBoot(page);
|
||||
|
||||
const result = await page.evaluate(async (key) => {
|
||||
const G7Core = (window as any).G7Core;
|
||||
const before = G7Core.state.getLocal()?.[key] ?? null;
|
||||
|
||||
await G7Core.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
||||
});
|
||||
|
||||
return {
|
||||
before,
|
||||
getLocal: G7Core.state.getLocal()?.[key] ?? null,
|
||||
canonical: (window as any).__templateApp?.getGlobalState?.()?._local?.[key] ?? null,
|
||||
};
|
||||
}, PROBE_KEY);
|
||||
|
||||
expect(result.before, '프로브 키가 이미 오염돼 있으면 이 테스트는 의미가 없다').toBeNull();
|
||||
// 결함이 있으면 여기서 null 이 나온다 — 커스텀 핸들러가 보던 그 값이다
|
||||
expect(result.getLocal).toBe('toss_naverpay');
|
||||
expect(result.canonical).toBe('toss_naverpay');
|
||||
});
|
||||
|
||||
test('동기화가 canonical 저장소의 다른 키를 지우지 않는다', async ({ page }) => {
|
||||
await page.goto(ANY_ENGINE_PAGE);
|
||||
await waitForEngineBoot(page);
|
||||
|
||||
const result = await page.evaluate(async (key) => {
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// 커스텀 핸들러가 setLocal 로 canonical 저장소에 먼저 기록한 상황을 만든다
|
||||
G7Core.state.setLocal({ [`${key}Keep`]: 'must-survive' });
|
||||
|
||||
await G7Core.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
||||
});
|
||||
|
||||
const canonical = (window as any).__templateApp?.getGlobalState?.()?._local ?? {};
|
||||
return { written: canonical[key] ?? null, kept: canonical[`${key}Keep`] ?? null };
|
||||
}, PROBE_KEY);
|
||||
|
||||
expect(result.written).toBe('toss_naverpay');
|
||||
// 부분 상태 스냅샷으로 canonical 을 통째 교체하면 여기서 사라진다
|
||||
expect(result.kept).toBe('must-survive');
|
||||
});
|
||||
|
||||
test('연속 기록이 서로를 지우지 않고 누적된다', async ({ page }) => {
|
||||
await page.goto(ANY_ENGINE_PAGE);
|
||||
await waitForEngineBoot(page);
|
||||
|
||||
const canonical = await page.evaluate(async (key) => {
|
||||
const G7Core = (window as any).G7Core;
|
||||
|
||||
// 결제수단을 골랐다가 다른 것으로 바꾸는 흐름 — 마지막 값이 남고 동반 키는 유지돼야 한다
|
||||
await G7Core.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: `_local.${key}`, value: 'toss_tosspay' },
|
||||
});
|
||||
await G7Core.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: `_local.${key}Sibling`, value: 'sibling-value' },
|
||||
});
|
||||
await G7Core.dispatch({
|
||||
handler: 'setState',
|
||||
params: { target: `_local.${key}`, value: 'toss_naverpay' },
|
||||
});
|
||||
|
||||
const local = (window as any).__templateApp?.getGlobalState?.()?._local ?? {};
|
||||
return { last: local[key] ?? null, sibling: local[`${key}Sibling`] ?? null };
|
||||
}, PROBE_KEY);
|
||||
|
||||
expect(canonical.last).toBe('toss_naverpay');
|
||||
expect(canonical.sibling).toBe('sibling-value');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user