fix(message_bizppurio): 알림톡 카카오 오류 사유 노출 + 탭 전환·목록 오류 표시
- 카카오 kapi HTTP 실패 시 응답 body의 message/code를 예외에 실어 운영자에게 실제 사유(IP 차단·반려 등)를 노출 (기존 고정 문구로 뭉개지던 문제 해소) - 설정 탭 전환을 replaceUrl→navigate(replace)로 교체 (URL만 바뀌고 화면이 전환되지 않던 문제 해소) - 템플릿 목록 조회 실패 시 카카오 사유를 빨간 배너로 표시 - report_url 데이터소스 if 조건으로 templates 탭 불필요 재조회 억제
This commit is contained in:
1 parent
17a95bb556
commit
fc00e30fc4
7 files changed
+179
-17
No files matched your search
+56
@@ -188,3 +188,59 @@ describe('plugin_settings.json — i18n 키 정합', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings.json — 탭 전환', () => {
|
||||
// 탭 버튼은 query.tab 을 바꾸며 화면(if 조건부 패널)을 다시 그려야 하므로
|
||||
// replaceUrl(URL만 변경, if 재평가 없음) 이 아니라 navigate 를 써야 한다.
|
||||
const tabButtonHandlers = (id: string): string[] => {
|
||||
const btn = findById(layout, id);
|
||||
const handlers: string[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.handler === 'string') handlers.push(n.handler);
|
||||
for (const v of Object.values(n)) {
|
||||
if (Array.isArray(v)) v.forEach(walk);
|
||||
else if (v && typeof v === 'object') walk(v);
|
||||
}
|
||||
};
|
||||
walk((btn as { actions?: unknown })?.actions);
|
||||
return handlers;
|
||||
};
|
||||
|
||||
it('환경설정 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
|
||||
const handlers = tabButtonHandlers('tab_connection');
|
||||
expect(handlers).toContain('navigate');
|
||||
expect(handlers).not.toContain('replaceUrl');
|
||||
});
|
||||
|
||||
it('알림톡 템플릿 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
|
||||
const handlers = tabButtonHandlers('tab_templates');
|
||||
expect(handlers).toContain('navigate');
|
||||
expect(handlers).not.toContain('replaceUrl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_settings.json — 목록 조회 실패 표시', () => {
|
||||
it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => {
|
||||
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
|
||||
const ds = sources.find((s) => s.id === 'alimtalk_templates') as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(ds).toBeTruthy();
|
||||
const onError = JSON.stringify(ds?.onError ?? {});
|
||||
// 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백
|
||||
expect(onError).toContain('templateListError');
|
||||
expect(onError).toContain('kakao_message');
|
||||
});
|
||||
|
||||
it('목록 오류 배너가 templateListError 조건으로 존재한다', () => {
|
||||
const banner = findById(layout, 'templates_list_error') as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(banner).toBeTruthy();
|
||||
expect(banner?.if).toBe('{{_local.templateListError}}');
|
||||
// 사유 본문을 그대로 렌더한다
|
||||
expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}');
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,9 @@
|
||||
"sender_key_missing": "Alimtalk sender profile key",
|
||||
"note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao."
|
||||
},
|
||||
"list_error": {
|
||||
"title": "Failed to load the template list"
|
||||
},
|
||||
"list": {
|
||||
"new": "New Template",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -95,6 +95,9 @@
|
||||
"sender_key_missing": "알림톡 발신프로필 키",
|
||||
"note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다."
|
||||
},
|
||||
"list_error": {
|
||||
"title": "템플릿 목록을 불러오지 못했습니다"
|
||||
},
|
||||
"list": {
|
||||
"new": "새 템플릿",
|
||||
"refresh": "새로고침",
|
||||
|
||||
+50
-9
@@ -34,7 +34,8 @@
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"initLocal": "reportInfo"
|
||||
"initLocal": "reportInfo",
|
||||
"if": "{{(query.tab ?? 'connection') === 'connection'}}"
|
||||
},
|
||||
{
|
||||
"id": "alimtalk_templates",
|
||||
@@ -46,6 +47,14 @@
|
||||
"params": {
|
||||
"status": "{{_local.templateStatus ?? ''}}",
|
||||
"keyword": "{{_local.templateKeyword ?? ''}}"
|
||||
},
|
||||
"onSuccess": {
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "templateListError": null }
|
||||
},
|
||||
"onError": {
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "templateListError": "{{error.errors?.kakao_message ?? error.message}}" }
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -95,8 +104,8 @@
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "replaceUrl",
|
||||
"params": { "query": { "tab": "connection" }, "mergeQuery": true }
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true }
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
@@ -116,14 +125,14 @@
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"actions": [
|
||||
{
|
||||
"handler": "replaceUrl",
|
||||
"params": { "query": { "tab": "templates" }, "mergeQuery": true }
|
||||
},
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "templateView": "list" }
|
||||
},
|
||||
{
|
||||
"handler": "navigate",
|
||||
"params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "templates" }, "mergeQuery": true, "replace": true }
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": { "dataSourceId": "alimtalk_templates" }
|
||||
@@ -150,7 +159,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "alert-info"
|
||||
"className": "mt-6 alert-info"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -919,6 +928,38 @@
|
||||
"if": "{{(query.tab ?? 'connection') === 'templates'}}",
|
||||
"props": { "className": "mt-6" },
|
||||
"children": [
|
||||
{
|
||||
"id": "templates_list_error",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.templateListError}}",
|
||||
"props": { "className": "mb-4 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 p-4 flex gap-2 items-start" },
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": { "name": "circle-exclamation", "size": "md", "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" }
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" },
|
||||
"text": "$t:sirsoft-message_bizppurio.templates.list_error.title"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"props": { "className": "text-xs text-red-700 dark:text-red-300 mt-1 leading-relaxed" },
|
||||
"text": "{{_local.templateListError}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "templates_readiness",
|
||||
"type": "basic",
|
||||
@@ -981,7 +1022,7 @@
|
||||
"name": "Button",
|
||||
"props": { "type": "button", "className": "btn btn-secondary btn-sm whitespace-nowrap" },
|
||||
"actions": [
|
||||
{ "type": "click", "handler": "replaceUrl", "params": { "query": { "tab": "connection" }, "mergeQuery": true } }
|
||||
{ "type": "click", "handler": "navigate", "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true } }
|
||||
],
|
||||
"children": [
|
||||
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.readiness.go_settings" }
|
||||
|
||||
+28
-8
@@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
|
||||
@@ -111,10 +112,7 @@ class BizppurioKakaoApiClient
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BizppurioApiException(
|
||||
__('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
throw $this->failureException($response);
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
@@ -178,10 +176,7 @@ class BizppurioKakaoApiClient
|
||||
]));
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new BizppurioApiException(
|
||||
__('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
throw $this->failureException($response);
|
||||
}
|
||||
|
||||
$result = $response->json();
|
||||
@@ -196,6 +191,31 @@ class BizppurioKakaoApiClient
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 실패 응답에서 카카오가 준 사유(message)와 결과코드(code)를 추출해 예외를 만듭니다.
|
||||
*
|
||||
* 카카오 관리 API 는 실패 시에도 `{code, message}` 봉투를 반환하므로, body 를 파싱해
|
||||
* 운영자에게 실제 사유(접근 불가 IP·반려 사유·계정 오류 등)를 노출한다. body 파싱이
|
||||
* 불가하거나 message 가 비어 있으면 일반 실패 문구로 폴백한다.
|
||||
*
|
||||
* @param Response $response 실패한 HTTP 응답
|
||||
* @return BizppurioApiException 카카오 사유·결과코드·HTTP 상태가 담긴 예외
|
||||
*/
|
||||
private function failureException(Response $response): BizppurioApiException
|
||||
{
|
||||
$body = $response->json();
|
||||
$message = is_array($body) ? (string) ($body['message'] ?? '') : '';
|
||||
$code = is_array($body) ? (string) ($body['code'] ?? '') : '';
|
||||
|
||||
return new BizppurioApiException(
|
||||
$message !== ''
|
||||
? $message
|
||||
: __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
|
||||
resultCode: $code !== '' ? $code : null,
|
||||
httpStatus: $response->status(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다.
|
||||
*
|
||||
|
||||
+36
@@ -134,6 +134,42 @@ class BizppurioKakaoApiClientTest extends PluginTestCase
|
||||
$this->client()->getSenderProfiles();
|
||||
}
|
||||
|
||||
public function test_http_실패시_카카오_message와_code를_예외에_싣는다(): void
|
||||
{
|
||||
Http::fake([
|
||||
'kapi.ppurio.com/*' => Http::response(
|
||||
['code' => '403', 'message' => '접근할 수 없는 IP 입니다. (114.207.113.206)'],
|
||||
403,
|
||||
),
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->client()->getSenderProfiles();
|
||||
$this->fail('BizppurioApiException 이 발생해야 한다.');
|
||||
} catch (BizppurioApiException $e) {
|
||||
$this->assertSame('접근할 수 없는 IP 입니다. (114.207.113.206)', $e->getMessage());
|
||||
$this->assertSame('403', $e->getResultCode());
|
||||
$this->assertSame(403, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_http_실패_body에_message가_없으면_일반문구로_폴백한다(): void
|
||||
{
|
||||
Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]);
|
||||
|
||||
try {
|
||||
$this->client()->getSenderProfiles();
|
||||
$this->fail('BizppurioApiException 이 발생해야 한다.');
|
||||
} catch (BizppurioApiException $e) {
|
||||
$this->assertSame(
|
||||
__('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
|
||||
$e->getMessage(),
|
||||
);
|
||||
$this->assertNull($e->getResultCode());
|
||||
$this->assertSame(500, $e->getHttpStatus());
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
|
||||
@@ -62,6 +62,9 @@ effects:
|
||||
- save_button_disabled_without_changes
|
||||
- save_puts_to_core_plugin_settings_api
|
||||
- only_registered_handlers_used
|
||||
# 탭 전환 (환경설정 ↔ 알림톡 템플릿) — query.tab 변경 시 if 조건부 패널 재평가 필요
|
||||
- tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀
|
||||
- alimtalk_template_list_error_shown_with_kakao_reason # 목록 조회 실패 시 카카오 사유(kakao_message)를 배너로 노출
|
||||
# 다국어
|
||||
- all_referenced_i18n_keys_exist_in_ko_and_en
|
||||
# 설치·활성화 (백엔드)
|
||||
|
||||
Reference in new issue
Block a user