Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/EcommerceSettingsOrderSettingsTest.php
T
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

947 lines
44 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use Illuminate\Support\Facades\File;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use ReflectionClass;
/**
* 이커머스 모듈 주문설정(order_settings) 카테고리 테스트
*
* - 기본값 로딩
* - getSetting() 조회
* - 결제수단 병합 (builtin + 플러그인 + 사용자 저장)
* - 고아 결제수단 감지
* - _cached_* 메타데이터 스냅샷
* - payment → order_settings 마이그레이션
* - bank_accounts CRUD
* - getFrontendSettings() bank_name 해석
*/
class EcommerceSettingsOrderSettingsTest extends ModuleTestCase
{
private EcommerceSettingsService $service;
private string $storagePath;
/**
* 테스트에서 등록한 필터 콜백 (tearDown에서 제거용)
*/
private array $registeredFilterCallbacks = [];
/**
* 원본 HookManager filters 백업 (다른 테스트의 static state 오염 방지)
*/
private array $originalFilters = [];
protected function setUp(): void
{
parent::setUp();
$this->storagePath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
// 다른 테스트에서 누적된 HookManager static filters 백업 후 초기화
$ref = new ReflectionClass(HookManager::class);
$prop = $ref->getProperty('filters');
$this->originalFilters = $prop->getValue();
$prop->setValue(null, []);
// 설정 디렉토리 초기화
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);
}
// HookManager filters를 원본으로 복원
$ref = new ReflectionClass(HookManager::class);
$prop = $ref->getProperty('filters');
$prop->setValue(null, $this->originalFilters);
$this->registeredFilterCallbacks = [];
parent::tearDown();
}
/**
* 결제수단 필터를 등록합니다.
*/
private function addPaymentMethodFilter(callable $callback): void
{
HookManager::addFilter(
'sirsoft-ecommerce.settings.filter_available_payment_methods',
$callback
);
}
// ──────────────────────────────────────────────
// 기본값 로딩
// ──────────────────────────────────────────────
public function test_defaults_include_order_settings_category(): void
{
$settings = $this->service->getAllSettings();
$this->assertArrayHasKey('order_settings', $settings);
}
/**
* testing 환경에서는 설정 저장 경로가 운영 경로(storage/app/modules/...)가 아닌
* 격리 경로(storage/framework/testing/...)여야 한다. 테스트가 운영 설정을
* 덮어쓰는 회귀를 차단한다.
*/
public function test_storage_path_is_isolated_in_testing_environment(): void
{
$method = (new ReflectionClass(EcommerceSettingsService::class))->getMethod('getStoragePath');
$method->setAccessible(true);
// 경로 구분자를 forward slash 로 정규화해 OS 무관 검증 (Windows 는 혼합 구분자 반환).
$normalized = str_replace('\\', '/', $method->invoke($this->service));
$this->assertStringContainsString('framework/testing', $normalized);
$this->assertStringNotContainsString('app/modules/sirsoft-ecommerce/settings', $normalized);
}
/**
* 설정 저장이 운영 mileage.json 등을 건드리지 않는지 검증한다 (운영 설정 영구 보존).
*/
public function test_saving_settings_does_not_touch_production_path(): void
{
$productionFile = storage_path('app/modules/sirsoft-ecommerce/settings/order_settings.json');
$existedBefore = File::exists($productionFile);
$contentBefore = $existedBefore ? File::get($productionFile) : null;
$this->service->saveSettings(['order_settings' => ['some_flag' => true]]);
if ($existedBefore) {
// 운영 파일이 이미 있었다면 내용이 변경되지 않아야 한다 (격리 경로에만 기록).
$this->assertSame(
$contentBefore,
File::get($productionFile),
'테스트가 운영 설정 파일 내용을 변경했습니다.'
);
} else {
// 운영 파일이 없었다면 저장 후에도 새로 생성되지 않아야 한다.
$this->assertFalse(File::exists($productionFile), '테스트가 운영 설정 파일을 생성했습니다.');
}
}
public function test_order_settings_default_values(): void
{
$settings = $this->service->getSettings('order_settings');
$this->assertTrue($settings['auto_cancel_expired']);
$this->assertEquals(3, $settings['auto_cancel_days']);
$this->assertEquals(30, $settings['cart_expiry_days']);
$this->assertTrue($settings['stock_restore_on_cancel']);
// 입금기한 단일화: 구 키는 기본 설정 표면에서 제거됨 (auto_cancel_days 단일 SSoT)
$this->assertArrayNotHasKey('vbank_due_days', $settings);
$this->assertArrayNotHasKey('dbank_due_days', $settings);
}
public function test_default_banks_loaded(): void
{
$settings = $this->service->getSettings('order_settings');
$this->assertArrayHasKey('banks', $settings);
$this->assertCount(17, $settings['banks']);
$this->assertEquals('004', $settings['banks'][0]['code']);
$this->assertEquals('국민은행', $settings['banks'][0]['name']['ko']);
}
public function test_default_bank_accounts_loaded(): void
{
$settings = $this->service->getSettings('order_settings');
$this->assertArrayHasKey('bank_accounts', $settings);
$this->assertCount(1, $settings['bank_accounts']);
$this->assertEquals('004', $settings['bank_accounts'][0]['bank_code']);
$this->assertFalse($settings['bank_accounts'][0]['is_default']);
}
// ──────────────────────────────────────────────
// getSetting() 조회
// ──────────────────────────────────────────────
public function test_get_setting_with_dot_notation(): void
{
$this->assertTrue(
$this->service->getSetting('order_settings.auto_cancel_expired', false)
);
$this->assertEquals(
3,
$this->service->getSetting('order_settings.auto_cancel_days')
);
}
public function test_get_setting_returns_default_for_missing_key(): void
{
$this->assertEquals(
'fallback',
$this->service->getSetting('order_settings.nonexistent_key', 'fallback')
);
}
// ──────────────────────────────────────────────
// 결제수단 병합 (builtin)
// ──────────────────────────────────────────────
public function test_default_builtin_payment_methods(): void
{
$settings = $this->service->getSettings('order_settings');
$methods = $settings['payment_methods'];
$this->assertCount(8, $methods);
$ids = array_column($methods, 'id');
$this->assertEquals(['card', 'vbank', 'dbank', 'bank', 'phone', 'point', 'deposit', 'free'], $ids);
}
public function test_builtin_payment_methods_have_cached_metadata(): void
{
$settings = $this->service->getSettings('order_settings');
$dbank = collect($settings['payment_methods'])->firstWhere('id', 'dbank');
$this->assertEquals(['ko' => '무통장입금', 'en' => 'Bank Transfer'], $dbank['_cached_name']);
$this->assertEquals('building-columns', $dbank['_cached_icon']);
$this->assertEquals('builtin', $dbank['_cached_source']);
}
// ──────────────────────────────────────────────
// 결제수단 병합 (플러그인 필터)
// ──────────────────────────────────────────────
public function test_plugin_payment_method_added_via_filter(): void
{
// 플러그인이 결제수단을 추가하는 시나리오
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'tosspayments',
'name' => ['ko' => '토스페이먼츠', 'en' => 'Toss Payments'],
'description' => ['ko' => '토스 간편결제', 'en' => 'Toss Easy Pay'],
'icon' => 'credit-card',
'source' => 'plugin:sirsoft-tosspayments',
'defaults' => [
'is_active' => false,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
],
];
return $methods;
});
// 캐시 초기화 후 재조회
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$methods = $settings['payment_methods'];
$this->assertCount(9, $methods); // 8 builtin + 1 plugin
$toss = collect($methods)->firstWhere('id', 'tosspayments');
$this->assertNotNull($toss);
$this->assertEquals(['ko' => '토스페이먼츠', 'en' => 'Toss Payments'], $toss['_cached_name']);
$this->assertEquals('plugin:sirsoft-tosspayments', $toss['_cached_source']);
$this->assertFalse($toss['is_active']); // 기본값: 비활성
}
/**
* @scenario mark_form=svg, requires_ios=false, device=non_ios
*
* @effects brand_mark_flows_to_cached, requires_ios_flag_carried, none_form_cached_brand_mark_null
*/
public function test_brand_mark_and_requires_ios_flow_from_definition_to_merged_catalog(): void
{
// 플러그인이 brand_mark(배지/SVG) + requires_ios 를 등록하면
// 병합 카탈로그가 _cached_brand_mark / requires_ios 로 노출해 레이아웃까지 전달한다.
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'pgtest_badge',
'name' => ['ko' => '배지결제', 'en' => 'Badge Pay'],
'description' => ['ko' => '배지 결제', 'en' => 'Badge Payment'],
'icon' => 'wallet',
'source' => 'plugin:sirsoft-pgtest',
'brand_mark' => ['text' => 'B', 'class' => 'bg-green-500 text-white'],
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
];
$methods[] = [
'id' => 'pgtest_svg_ios',
'name' => ['ko' => 'SVG결제', 'en' => 'SVG Pay'],
'description' => ['ko' => 'SVG 결제', 'en' => 'SVG Payment'],
'icon' => 'smartphone',
'source' => 'plugin:sirsoft-pgtest',
'brand_mark' => ['svg' => '<svg><rect fill="#03C75A"/></svg>'],
'requires_ios' => true,
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
];
return $methods;
});
$this->service->clearCache();
$methods = collect($this->service->getSettings('order_settings')['payment_methods'])->keyBy('id');
$badge = $methods->get('pgtest_badge');
$this->assertSame(['text' => 'B', 'class' => 'bg-green-500 text-white'], $badge['_cached_brand_mark']);
$this->assertArrayNotHasKey('requires_ios', $badge);
$svg = $methods->get('pgtest_svg_ios');
$this->assertSame(['svg' => '<svg><rect fill="#03C75A"/></svg>'], $svg['_cached_brand_mark']);
$this->assertTrue($svg['requires_ios']);
// brand_mark 없는 builtin 은 _cached_brand_mark 가 null.
$dbank = $methods->get('dbank');
$this->assertNull($dbank['_cached_brand_mark']);
}
/**
* 플러그인 결제수단이 defaults.core_payment_method 를 선언하면 병합 결과에 보존되어야 한다.
*
* 배경(#454): toss_* 등 플러그인 결제수단은 코어 PaymentMethodEnum 이 거부하므로, 프론트가
* 주문 생성 시 이 core 값을 payment_method 로 전송해야 한다. 병합이 이 필드를 떨구면
* 프론트가 번역 근거를 잃어 원시 id 를 전송 → 422. _cached_* 처럼 provider-agnostic 하게 보존한다.
*/
public function test_plugin_payment_method_core_payment_method_preserved_after_merge(): void
{
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'toss_virtual_account',
'name' => ['ko' => '가상계좌 (토스페이먼츠)', 'en' => 'Virtual Account (Toss)'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'building-columns',
'source' => 'plugin:sirsoft-tosspayments',
'defaults' => [
'pg_provider' => null,
'is_active' => false,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'core_payment_method' => 'vbank',
],
];
return $methods;
});
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$toss = collect($settings['payment_methods'])->firstWhere('id', 'toss_virtual_account');
$this->assertNotNull($toss);
$this->assertSame('vbank', $toss['core_payment_method'] ?? null, '병합이 core_payment_method 를 떨궜습니다.');
// core_payment_method 를 선언하지 않은 builtin 은 이 키가 없어야 한다 (KG 인터셉터 방식 무영향).
$dbank = collect($settings['payment_methods'])->firstWhere('id', 'dbank');
$this->assertArrayNotHasKey('core_payment_method', $dbank);
}
/**
* 저장(snapshot) 후에도 플러그인 결제수단의 core_payment_method 가 보존되어야 한다.
*
* 사용자가 결제수단을 저장하면 snapshotPaymentMethodMetadata 가 _cached_* 를 재적재하는데,
* 이때 core_payment_method 도 함께 스냅샷되어야 재조회 응답(프론트 소비)에 남는다.
*/
public function test_plugin_payment_method_core_payment_method_preserved_after_save(): void
{
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'toss_virtual_account',
'name' => ['ko' => '가상계좌 (토스페이먼츠)', 'en' => 'Virtual Account (Toss)'],
'description' => ['ko' => '', 'en' => ''],
'icon' => 'building-columns',
'source' => 'plugin:sirsoft-tosspayments',
'defaults' => [
'pg_provider' => null,
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'core_payment_method' => 'vbank',
],
];
return $methods;
});
$this->saveOrderSettings([
'payment_methods' => [
['id' => 'dbank', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
['id' => 'toss_virtual_account', 'sort_order' => 2, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
],
]);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$toss = collect($settings['payment_methods'])->firstWhere('id', 'toss_virtual_account');
$this->assertNotNull($toss);
$this->assertSame('vbank', $toss['core_payment_method'] ?? null, '저장 스냅샷이 core_payment_method 를 떨궜습니다.');
}
// ──────────────────────────────────────────────
// 결제수단 병합 (사용자 저장 설정 오버라이드)
// ──────────────────────────────────────────────
public function test_user_settings_override_payment_method_defaults(): void
{
// 사용자가 결제수단 순서를 변경하고 dbank를 비활성화한 설정 저장
$savedSettings = [
'payment_methods' => [
['id' => 'point', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'dbank', 'sort_order' => 2, 'is_active' => false, 'min_order_amount' => 10000, 'stock_deduction_timing' => 'order_placed'],
['id' => 'deposit', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 5000, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'card', 'sort_order' => 4, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'vbank', 'sort_order' => 5, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'bank', 'sort_order' => 6, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'phone', 'sort_order' => 7, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'free', 'sort_order' => 8, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
],
];
$this->saveOrderSettings($savedSettings);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$methods = $settings['payment_methods'];
// sort_order 순 정렬 확인 (point=1, dbank=2, deposit=3, ...)
$this->assertCount(8, $methods);
$this->assertEquals('point', $methods[0]['id']);
$this->assertEquals('dbank', $methods[1]['id']);
$this->assertEquals('deposit', $methods[2]['id']);
// 사용자 오버라이드 확인
$dbank = collect($methods)->firstWhere('id', 'dbank');
$this->assertFalse($dbank['is_active']);
$this->assertEquals(10000, $dbank['min_order_amount']);
}
// ──────────────────────────────────────────────
// 고아 결제수단 감지
// ──────────────────────────────────────────────
public function test_orphaned_payment_method_detected(): void
{
// 이전에 tosspayments 플러그인이 있었지만 지금은 비활성화된 시나리오
$savedSettings = [
'payment_methods' => [
['id' => 'card', 'sort_order' => 1, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'vbank', 'sort_order' => 2, 'is_active' => false, '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' => 'bank', 'sort_order' => 4, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'phone', 'sort_order' => 5, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'point', 'sort_order' => 6, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'deposit', 'sort_order' => 7, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'free', 'sort_order' => 8, 'is_active' => false, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
[
'id' => 'tosspayments',
'sort_order' => 9,
'is_active' => true,
'min_order_amount' => 0,
'stock_deduction_timing' => 'payment_complete',
'_cached_name' => ['ko' => '토스페이먼츠', 'en' => 'Toss Payments'],
'_cached_icon' => 'credit-card',
'_cached_source' => 'plugin:sirsoft-tosspayments',
],
],
];
$this->saveOrderSettings($savedSettings);
$this->service->clearCache();
// 플러그인 필터를 등록하지 않음 → tosspayments는 available에 없음
$settings = $this->service->getSettings('order_settings');
$methods = $settings['payment_methods'];
$this->assertCount(9, $methods); // 8 builtin + 1 orphan
$toss = collect($methods)->firstWhere('id', 'tosspayments');
$this->assertNotNull($toss);
$this->assertTrue($toss['_orphaned'] ?? false);
// 고아 항목은 목록 끝에 배치
$lastMethod = end($methods);
$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_* 메타데이터 스냅샷
// ──────────────────────────────────────────────
public function test_cached_metadata_snapshot_on_save(): void
{
// 플러그인 결제수단 추가
$this->addPaymentMethodFilter(function (array $methods) {
$methods[] = [
'id' => 'kakaopay',
'name' => ['ko' => '카카오페이', 'en' => 'Kakao Pay'],
'description' => ['ko' => '카카오페이 결제', 'en' => 'Kakao Pay Payment'],
'icon' => 'mobile',
'source' => 'plugin:sirsoft-kakaopay',
'defaults' => ['is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
];
return $methods;
});
// 저장 시 _cached_* 스냅샷 확인
$this->service->clearCache();
$this->service->saveSettings([
'order_settings' => [
'payment_methods' => [
['id' => 'dbank', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
['id' => 'point', 'sort_order' => 2, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'deposit', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'kakaopay', 'sort_order' => 4, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
],
'auto_cancel_expired' => true,
'auto_cancel_days' => 3,
'cart_expiry_days' => 30,
'stock_restore_on_cancel' => true,
],
]);
// 저장된 파일에서 직접 읽어 _cached_* 확인
$saved = json_decode(File::get($this->storagePath.'/order_settings.json'), true);
$kakaopay = collect($saved['payment_methods'])->firstWhere('id', 'kakaopay');
$this->assertEquals(['ko' => '카카오페이', 'en' => 'Kakao Pay'], $kakaopay['_cached_name']);
$this->assertEquals('mobile', $kakaopay['_cached_icon']);
$this->assertEquals('plugin:sirsoft-kakaopay', $kakaopay['_cached_source']);
// _orphaned 플래그는 저장되지 않아야 함
$this->assertArrayNotHasKey('_orphaned', $kakaopay);
}
// ──────────────────────────────────────────────
// payment → order_settings 마이그레이션
// ──────────────────────────────────────────────
public function test_migrate_payment_to_order_settings(): void
{
// 기존 payment.json 생성 (레거시 형식)
$paymentData = [
'banks' => [
['code' => '004', 'name' => ['ko' => '국민은행', 'en' => 'Kookmin Bank']],
],
'bank_accounts' => [
['bank_code' => '004', 'account_number' => '123-456', 'account_holder' => '홍길동', 'is_active' => true, 'is_default' => true],
],
'auto_cancel_expired' => false,
'auto_cancel_days' => 5,
'vbank_due_days' => 5,
'dbank_due_days' => 10,
];
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/payment.json', json_encode($paymentData));
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
// 마이그레이션된 값 확인
$this->assertFalse($settings['auto_cancel_expired']);
$this->assertEquals(5, $settings['auto_cancel_days']);
// 입금기한 단일화: 구 키는 더 이상 마이그레이션 대상이 아님 (auto_cancel_days 단일 SSoT)
$this->assertArrayNotHasKey('vbank_due_days', $settings);
$this->assertArrayNotHasKey('dbank_due_days', $settings);
// bank_accounts 이전 확인
$this->assertCount(1, $settings['bank_accounts']);
$this->assertEquals('홍길동', $settings['bank_accounts'][0]['account_holder']);
// order_settings.json이 생성되었는지 확인
$this->assertTrue(File::exists($this->storagePath.'/order_settings.json'));
// payment.json은 보존되어야 함
$this->assertTrue(File::exists($this->storagePath.'/payment.json'));
}
public function test_migrate_dbank_single_fields_to_bank_accounts(): void
{
// 기존 단일 필드 형식 (dbank_bank_code 등)
$paymentData = [
'dbank_bank_code' => '088',
'dbank_account_number' => '110-123-456',
'dbank_account_holder' => '김철수',
'auto_cancel_expired' => true,
];
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/payment.json', json_encode($paymentData));
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
// dbank_* → bank_accounts[0] 변환 확인
$this->assertCount(1, $settings['bank_accounts']);
$this->assertEquals('088', $settings['bank_accounts'][0]['bank_code']);
$this->assertEquals('110-123-456', $settings['bank_accounts'][0]['account_number']);
$this->assertEquals('김철수', $settings['bank_accounts'][0]['account_holder']);
$this->assertTrue($settings['bank_accounts'][0]['is_default']);
}
public function test_migration_skipped_when_order_settings_exists(): void
{
// order_settings.json이 이미 있으면 마이그레이션 스킵
$orderSettingsData = ['auto_cancel_expired' => false, 'auto_cancel_days' => 7];
$paymentData = ['auto_cancel_expired' => true, 'auto_cancel_days' => 3];
File::ensureDirectoryExists($this->storagePath);
File::put($this->storagePath.'/order_settings.json', json_encode($orderSettingsData));
File::put($this->storagePath.'/payment.json', json_encode($paymentData));
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
// order_settings.json의 값이 사용됨 (payment.json 값 아님)
$this->assertFalse($settings['auto_cancel_expired']);
$this->assertEquals(7, $settings['auto_cancel_days']);
}
// ──────────────────────────────────────────────
// order_settings 카테고리 저장/로드
// ──────────────────────────────────────────────
public function test_save_and_load_order_settings(): void
{
$this->service->saveSettings([
'order_settings' => [
'auto_cancel_expired' => false,
'auto_cancel_days' => 5,
'cart_expiry_days' => 60,
'stock_restore_on_cancel' => false,
],
]);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$this->assertFalse($settings['auto_cancel_expired']);
$this->assertEquals(5, $settings['auto_cancel_days']);
$this->assertEquals(60, $settings['cart_expiry_days']);
$this->assertFalse($settings['stock_restore_on_cancel']);
}
// ──────────────────────────────────────────────
// 은행 CRUD
// ──────────────────────────────────────────────
public function test_save_bank_accounts_crud(): void
{
// 2개 계좌 저장
$this->service->saveSettings([
'order_settings' => [
'bank_accounts' => [
['bank_code' => '004', 'account_number' => '123-456', 'account_holder' => '홍길동', 'is_active' => true, 'is_default' => true],
['bank_code' => '088', 'account_number' => '789-012', 'account_holder' => '김영희', 'is_active' => true, 'is_default' => false],
],
],
]);
$this->service->clearCache();
$accounts = $this->service->getSettings('order_settings')['bank_accounts'];
$this->assertCount(2, $accounts);
$this->assertEquals('홍길동', $accounts[0]['account_holder']);
$this->assertTrue($accounts[0]['is_default']);
$this->assertFalse($accounts[1]['is_default']);
}
// ──────────────────────────────────────────────
// getAllSettings() — bank_name 해석 및 필드 검증
// (order_settings는 expose: false → getFrontendSettings()에 미포함,
// Admin API는 getAllSettings() 사용)
// ──────────────────────────────────────────────
public function test_all_settings_includes_bank_accounts(): void
{
// bank_accounts에 은행 코드 설정
$this->service->saveSettings([
'order_settings' => [
'bank_accounts' => [
['bank_code' => '004', 'account_number' => '123-456', 'account_holder' => '홍길동', 'is_active' => true, 'is_default' => true],
['bank_code' => '088', 'account_number' => '789-012', 'account_holder' => '김영희', 'is_active' => true, 'is_default' => false],
],
],
]);
$this->service->clearCache();
$allSettings = $this->service->getAllSettings();
$this->assertArrayHasKey('order_settings', $allSettings);
$accounts = $allSettings['order_settings']['bank_accounts'];
// bank_accounts가 저장된 데이터를 포함하는지 확인
$this->assertCount(2, $accounts);
$this->assertEquals('004', $accounts[0]['bank_code']);
$this->assertEquals('088', $accounts[1]['bank_code']);
}
public function test_public_payment_settings_resolves_bank_name(): void
{
// bank_accounts에 은행 코드 설정
$this->service->saveSettings([
'order_settings' => [
'bank_accounts' => [
['bank_code' => '004', 'account_number' => '123-456', 'account_holder' => '홍길동', 'is_active' => true, 'is_default' => true],
['bank_code' => '088', 'account_number' => '789-012', 'account_holder' => '김영희', 'is_active' => true, 'is_default' => false],
],
],
]);
$this->service->clearCache();
$paymentSettings = $this->service->getPublicPaymentSettings();
$accounts = $paymentSettings['bank_accounts'];
// bank_name이 은행코드로부터 해석되었는지 확인 (getPublicPaymentSettings에서 해석)
$this->assertEquals(['ko' => '국민은행', 'en' => 'Kookmin Bank'], $accounts[0]['bank_name']);
$this->assertEquals(['ko' => '신한은행', 'en' => 'Shinhan Bank'], $accounts[1]['bank_name']);
}
public function test_all_settings_includes_order_settings_fields(): void
{
$allSettings = $this->service->getAllSettings();
$this->assertArrayHasKey('order_settings', $allSettings);
// order_settings 필드가 포함되어야 함
$this->assertArrayHasKey('auto_cancel_expired', $allSettings['order_settings']);
$this->assertArrayHasKey('auto_cancel_days', $allSettings['order_settings']);
$this->assertArrayHasKey('stock_restore_on_cancel', $allSettings['order_settings']);
$this->assertArrayHasKey('payment_methods', $allSettings['order_settings']);
// 입금기한 단일화: 구 키는 전체 설정에서 제거됨
$this->assertArrayNotHasKey('vbank_due_days', $allSettings['order_settings']);
$this->assertArrayNotHasKey('dbank_due_days', $allSettings['order_settings']);
}
public function test_all_settings_boolean_fields_are_boolean_type(): void
{
$allSettings = $this->service->getAllSettings();
$this->assertIsBool(
$allSettings['order_settings']['auto_cancel_expired'],
'auto_cancel_expired는 boolean 타입이어야 합니다'
);
$this->assertIsBool(
$allSettings['order_settings']['stock_restore_on_cancel'],
'stock_restore_on_cancel은 boolean 타입이어야 합니다'
);
}
public function test_frontend_settings_excludes_order_settings(): void
{
$frontendSettings = $this->service->getFrontendSettings();
// order_settings는 expose: false → getFrontendSettings()에 미포함
$this->assertArrayNotHasKey('order_settings', $frontendSettings);
}
// ──────────────────────────────────────────────
// saveBanks() — 은행 목록만 별도 저장
// ──────────────────────────────────────────────
public function test_save_banks_only_preserves_other_order_settings(): void
{
// 먼저 전체 order_settings 저장
$this->service->saveSettings([
'order_settings' => [
'auto_cancel_expired' => false,
'auto_cancel_days' => 10,
'bank_accounts' => [
['bank_code' => '004', 'account_number' => '123-456', 'account_holder' => '홍길동', 'is_active' => true, 'is_default' => true],
],
],
]);
$this->service->clearCache();
// saveBanks로 은행만 교체
$newBanks = [
['code' => '999', 'name' => ['ko' => '테스트은행', 'en' => 'Test Bank']],
];
$this->service->saveBanks($newBanks);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
// 은행이 교체되었는지 확인
$this->assertCount(1, $settings['banks']);
$this->assertEquals('999', $settings['banks'][0]['code']);
$this->assertEquals('테스트은행', $settings['banks'][0]['name']['ko']);
// 다른 설정은 보존되었는지 확인
$this->assertFalse($settings['auto_cancel_expired']);
$this->assertEquals(10, $settings['auto_cancel_days']);
$this->assertCount(1, $settings['bank_accounts']);
$this->assertEquals('홍길동', $settings['bank_accounts'][0]['account_holder']);
}
public function test_save_banks_with_empty_array(): void
{
// 기본 설정 로드 (은행 17개)
$settings = $this->service->getSettings('order_settings');
$this->assertCount(17, $settings['banks']);
// 빈 배열로 저장
$this->service->saveBanks([]);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$this->assertCount(0, $settings['banks']);
}
public function test_save_banks_returns_true_on_success(): void
{
$result = $this->service->saveBanks([
['code' => '004', 'name' => ['ko' => '국민은행', 'en' => 'Kookmin Bank']],
]);
$this->assertTrue($result);
}
// ──────────────────────────────────────────────
// 고아 항목 삭제
// ──────────────────────────────────────────────
public function test_orphaned_item_removed_on_save(): void
{
// 고아 항목 포함된 상태에서 해당 항목 제외하고 저장
$savedSettings = [
'payment_methods' => [
['id' => 'dbank', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
['id' => 'point', 'sort_order' => 2, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'deposit', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
// tosspayments 항목을 제외 (관리자가 삭제)
],
];
$this->service->saveSettings(['order_settings' => $savedSettings]);
$this->service->clearCache();
$settings = $this->service->getSettings('order_settings');
$ids = array_column($settings['payment_methods'], 'id');
$this->assertNotContains('tosspayments', $ids);
}
// ──────────────────────────────────────────────
// getStockDeductionTiming()
// ──────────────────────────────────────────────
public function test_get_stock_deduction_timing_returns_configured_value(): void
{
// dbank는 기본값이 order_placed
$this->saveOrderSettings([
'payment_methods' => [
['id' => 'dbank', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
['id' => 'card', 'sort_order' => 2, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'payment_complete'],
['id' => 'free', 'sort_order' => 3, 'is_active' => true, 'min_order_amount' => 0, 'stock_deduction_timing' => 'order_placed'],
],
]);
$this->service->clearCache();
$this->assertEquals('order_placed', $this->service->getStockDeductionTiming('dbank'));
$this->assertEquals('payment_complete', $this->service->getStockDeductionTiming('card'));
$this->assertEquals('order_placed', $this->service->getStockDeductionTiming('free'));
}
public function test_get_stock_deduction_timing_returns_default_for_unknown_method(): void
{
$this->service->clearCache();
// 존재하지 않는 결제수단 → 기본값 payment_complete
$this->assertEquals('payment_complete', $this->service->getStockDeductionTiming('nonexistent_method'));
}
public function test_get_stock_deduction_timing_uses_defaults_when_no_saved_settings(): void
{
$this->service->clearCache();
// 저장된 설정 없이 defaults.json에서 로드
// dbank 기본값: order_placed, card 기본값: payment_complete
$this->assertEquals('order_placed', $this->service->getStockDeductionTiming('dbank'));
$this->assertEquals('payment_complete', $this->service->getStockDeductionTiming('card'));
}
// ──────────────────────────────────────────────
// 헬퍼
// ──────────────────────────────────────────────
/**
* order_settings.json을 직접 생성하여 저장된 설정을 시뮬레이션합니다.
*/
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)
);
}
}