Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsCurrencyDefaultFlagTest.php
HeuJung 30ad72a3a1 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 에 등록했다.
2026-08-10 17:17:51 +09:00

196 lines
8.1 KiB
PHP

<?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 로 남아 미지원 통화 차단에 걸려야 합니다.');
}
}