fix(kginicis): 설정 저장 오류 안내 개선

This commit is contained in:
thisgun committed 2026-07-07 18:19:17 +09:00
1 parent c6cab1f668
commit 88116de788
10 files changed
+203 -11

No files matched your search

@@ -9,6 +9,7 @@
### Fixed
- 이커머스 주문설정 화면에서 KG 이니시스 플러그인이 테스트 모드일 때 운영 전환 경고와 설정 이동 버튼을 표시하도록 보강했습니다.
- KG 이니시스 설정 저장 실패 시 검증 오류가 화면 상단에만 남아 사용자가 원인을 놓치지 않도록 오류 영역으로 자동 이동하고, 일본 결제(CBT) 샘플값 안내를 한 번만 표시하도록 개선했습니다.
## [1.0.0] - 2026-07-01
@@ -30,7 +30,7 @@ return [
'japan_contact_email_required' => 'Contact email for the live Japan payment window is required.',
'japan_contact_phone_required' => 'Contact phone for the live Japan payment window is required.',
'japan_contact_opening_hours_required' => 'Contact opening hours for the live Japan payment window are required.',
'replace_sample_value' => 'Replace sample values with your real contract information in live mode.',
'replace_sample_value' => 'Replace the sample Japan merchant display information with your real contract information in live mode.',
],
'defaults' => [
'good_name' => 'Goods',
@@ -30,7 +30,7 @@ return [
'japan_contact_email_required' => '운영 일본 결제창에 표시할 문의 이메일이 필요합니다.',
'japan_contact_phone_required' => '운영 일본 결제창에 표시할 문의 전화번호가 필요합니다.',
'japan_contact_opening_hours_required' => '운영 일본 결제창에 표시할 문의 영업시간이 필요합니다.',
'replace_sample_value' => '운영 모드에서는 샘플값 대신 실제 계약 정보를 입력하세요.',
'replace_sample_value' => '운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.',
],
'defaults' => [
'good_name' => '상품',
@@ -49,6 +49,21 @@ function collectClassNames(node: unknown, classNames: string[] = []): string[] {
return classNames;
}
function collectNodes(node: unknown, predicate: (value: Record<string, unknown>) => boolean, nodes: Record<string, unknown>[] = []): Record<string, unknown>[] {
if (!node || typeof node !== 'object') {
return nodes;
}
const value = node as Record<string, unknown>;
if (predicate(value)) {
nodes.push(value);
}
for (const child of Object.values(value)) {
collectNodes(child, predicate, nodes);
}
return nodes;
}
describe('plugin_settings 하단 버튼 sticky 고정', () => {
it('footer_buttons 에 sticky bottom 고정 클래스가 존재해야 한다', () => {
const footer = findById(pluginSettingsLayout, 'footer_buttons');
@@ -80,4 +95,57 @@ describe('plugin_settings 하단 버튼 sticky 고정', () => {
expect(className).not.toContain('row-stack');
}
});
it('저장 실패 시 apiCall onError 에서 validation errors 를 error 컨텍스트에서 읽어야 한다', () => {
const saveActions = collectNodes(pluginSettingsLayout, (node) => {
return node.handler === 'apiCall'
&& typeof node.target === 'string'
&& node.target.includes('/api/admin/plugins/')
&& node.target.includes('/settings');
});
expect(saveActions).toHaveLength(1);
const onError = saveActions[0].onError as Record<string, unknown>[] | undefined;
expect(onError).toBeDefined();
expect(onError?.[0]?.handler).toBe('setState');
const params = (onError?.[0]?.params ?? {}) as Record<string, unknown>;
expect(params.errors).toBe('{{error.errors ?? { _general: error.message }}}');
});
it('저장 실패 시 상단 검증 오류 영역으로 자동 스크롤해야 한다', () => {
const validationError = collectNodes(pluginSettingsLayout, (node) => {
return node.id === 'validation_error';
})[0];
expect(validationError).toBeDefined();
const validationErrorProps = (validationError.props ?? {}) as Record<string, unknown>;
expect(validationErrorProps.id).toBe('kginicis_settings_validation_error');
expect(validationErrorProps.role).toBe('alert');
expect(String(validationErrorProps.className)).toContain('scroll-mt-32');
const saveActions = collectNodes(pluginSettingsLayout, (node) => {
return node.handler === 'apiCall'
&& typeof node.target === 'string'
&& node.target.includes('/api/admin/plugins/')
&& node.target.includes('/settings');
});
expect(saveActions).toHaveLength(1);
const onError = saveActions[0].onError as Record<string, unknown>[] | undefined;
const scrollAction = onError?.find((action) => action.handler === 'scrollIntoView');
expect(scrollAction).toBeDefined();
const params = (scrollAction?.params ?? {}) as Record<string, unknown>;
expect(params).toMatchObject({
selector: '#kginicis_settings_validation_error',
behavior: 'smooth',
block: 'start',
waitForElement: true,
});
});
});
@@ -53,7 +53,7 @@
"japan_contact_email_required": "Contact email for the live Japan payment window is required.",
"japan_contact_phone_required": "Contact phone for the live Japan payment window is required.",
"japan_contact_opening_hours_required": "Contact opening hours for the live Japan payment window are required.",
"replace_sample_value": "Replace sample values with your real contract information in live mode."
"replace_sample_value": "Replace the sample Japan merchant display information with your real contract information in live mode."
}
},
"payment_error": {
@@ -53,7 +53,7 @@
"japan_contact_email_required": "운영 일본 결제창에 표시할 문의 이메일이 필요합니다.",
"japan_contact_phone_required": "운영 일본 결제창에 표시할 문의 전화번호가 필요합니다.",
"japan_contact_opening_hours_required": "운영 일본 결제창에 표시할 문의 영업시간이 필요합니다.",
"replace_sample_value": "운영 모드에서는 샘플값 대신 실제 계약 정보를 입력하세요."
"replace_sample_value": "운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요."
}
},
"payment_error": {
@@ -61,7 +61,11 @@
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"className": "alert-danger"
"id": "kginicis_settings_validation_error",
"role": "alert",
"aria-live": "assertive",
"tabIndex": -1,
"className": "alert-danger scroll-mt-32"
},
"children": [
{
@@ -2688,7 +2692,17 @@
"params": {
"target": "local",
"isSaving": false,
"errors": "{{$error.errors}}"
"errors": "{{error.errors ?? { _general: error.message }}}"
}
},
{
"handler": "scrollIntoView",
"params": {
"selector": "#kginicis_settings_validation_error",
"behavior": "smooth",
"block": "start",
"waitForElement": true,
"timeout": 2000
}
},
{
@@ -36,12 +36,15 @@ class ValidateCbtSettingsListener implements HookListenerInterface
'japan_contact_opening_hours' => '10:00-18:00',
];
private const SAMPLE_VALUE_ERROR_FIELD = 'japan_contract_info';
public static function getSubscribedHooks(): array
{
return [
'core.plugin_settings.before_save' => [
'method' => 'validateBeforeSave',
'priority' => 10,
'sync' => true,
],
];
}
@@ -73,10 +76,10 @@ class ValidateCbtSettingsListener implements HookListenerInterface
}
}
foreach (self::SAMPLE_VALUES as $field => $sampleValue) {
if (trim((string) ($settings[$field] ?? '')) === $sampleValue) {
$errors[$field][] = __('sirsoft-pay_kginicis::messages.settings_validation.replace_sample_value');
}
if ($this->hasSampleDisplayValue($settings)) {
$errors[self::SAMPLE_VALUE_ERROR_FIELD][] = __(
'sirsoft-pay_kginicis::messages.settings_validation.replace_sample_value'
);
}
}
@@ -103,4 +106,15 @@ class ValidateCbtSettingsListener implements HookListenerInterface
{
return trim((string) $value) === '';
}
private function hasSampleDisplayValue(array $settings): bool
{
foreach (self::SAMPLE_VALUES as $field => $sampleValue) {
if (trim((string) ($settings[$field] ?? '')) === $sampleValue) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\PayKginicis\Tests\Feature\Controllers;
use App\Extension\HookManager;
use Plugins\Sirsoft\PayKginicis\Listeners\ValidateCbtSettingsListener;
use Plugins\Sirsoft\PayKginicis\Tests\PluginTestCase;
class AdminPluginSettingsValidationTest extends PluginTestCase
{
public function test_live_cbt_settings_validation_returns_field_errors_on_save(): void
{
app()->setLocale('ko');
$admin = $this->createAdminUser(['core.plugins.update']);
$originalBeforeSaveHooks = HookManager::getHooks()['core.plugin_settings.before_save'] ?? [];
HookManager::addAction(
'core.plugin_settings.before_save',
fn (string $identifier, array $settings) => app(ValidateCbtSettingsListener::class)
->validateBeforeSave($identifier, $settings),
1
);
try {
$response = $this->actingAs($admin)
->withHeaders(['Accept-Language' => 'ko'])
->putJson('/api/admin/plugins/sirsoft-pay_kginicis/settings', [
'is_test_mode' => false,
'japan_enabled' => true,
'test_japan_sign_key' => 'test-cbt-hash-key',
'live_japan_mid' => '',
'live_japan_sign_key' => '',
'japan_merchant_name' => 'サンプルストア',
'japan_merchant_name_kana' => 'サンプルストア',
'japan_merchant_name_alphabet' => 'Sample Store',
'japan_merchant_name_short' => 'サンプル',
'japan_contact_name' => 'サポート窓口',
'japan_contact_email' => 'support@example.com',
'japan_contact_phone' => '0120-123-456',
'japan_contact_opening_hours' => '10:00-18:00',
]);
$response->assertStatus(422)
->assertJsonPath(
'errors.live_japan_mid.0',
'운영 모드에서 일본 결제(CBT)를 사용하려면 라이브 일본 MID가 필요합니다.'
)
->assertJsonPath(
'errors.japan_contract_info.0',
'운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.'
);
$errors = $response->json('errors');
$this->assertCount(1, $errors['japan_contract_info'] ?? []);
$this->assertArrayNotHasKey('japan_merchant_name', $errors);
$this->assertArrayNotHasKey('japan_merchant_name_alphabet', $errors);
$this->assertArrayNotHasKey('japan_contact_email', $errors);
} finally {
$this->restoreBeforeSaveHooks($originalBeforeSaveHooks);
}
}
/**
* @param array<int, array<int, callable>> $hooks
*/
private function restoreBeforeSaveHooks(array $hooks): void
{
HookManager::clearAction('core.plugin_settings.before_save');
foreach ($hooks as $priority => $callbacks) {
foreach ($callbacks as $callback) {
HookManager::addAction('core.plugin_settings.before_save', $callback, (int) $priority);
}
}
}
}
@@ -13,6 +13,11 @@ class ValidateCbtSettingsListenerTest extends PluginTestCase
public function test_plugin_registers_cbt_settings_validation_listener(): void
{
$this->assertContains(ValidateCbtSettingsListener::class, (new Plugin())->getHookListeners());
$hooks = ValidateCbtSettingsListener::getSubscribedHooks();
$this->assertSame('validateBeforeSave', $hooks['core.plugin_settings.before_save']['method'] ?? null);
$this->assertSame(10, $hooks['core.plugin_settings.before_save']['priority'] ?? null);
$this->assertTrue($hooks['core.plugin_settings.before_save']['sync'] ?? false);
}
public function test_plugin_schema_includes_jpy_payment_method_restriction_option(): void
@@ -120,16 +125,27 @@ class ValidateCbtSettingsListenerTest extends PluginTestCase
public function test_live_japan_payment_rejects_sample_jppg_display_values(): void
{
$this->mockCurrentSettings([]);
app()->setLocale('ko');
$listener = new ValidateCbtSettingsListener();
try {
$listener->validateBeforeSave('sirsoft-pay_kginicis', $this->validLiveSettings([
'japan_merchant_name' => 'サンプルストア',
'japan_merchant_name_alphabet' => 'Sample Store',
'japan_contact_email' => 'support@example.com',
]));
$this->fail('ValidationException was not thrown.');
} catch (ValidationException $e) {
$this->assertArrayHasKey('japan_merchant_name', $e->errors());
$errors = $e->errors();
$this->assertArrayHasKey('japan_contract_info', $errors);
$this->assertArrayNotHasKey('japan_merchant_name', $errors);
$this->assertArrayNotHasKey('japan_merchant_name_alphabet', $errors);
$this->assertArrayNotHasKey('japan_contact_email', $errors);
$this->assertSame([
'운영 모드에서는 일본 가맹점 표시 정보의 샘플값을 실제 계약 정보로 변경하세요.',
], $errors['japan_contract_info']);
}
}