fix(message_bizppurio): 알림톡 카카오 오류 사유 노출 + 탭 전환·목록 오류 표시

- 카카오 kapi HTTP 실패 시 응답 body의 message/code를 예외에 실어
 운영자에게 실제 사유(IP 차단·반려 등)를 노출 (기존 고정 문구로 뭉개지던 문제 해소)
- 설정 탭 전환을 replaceUrl→navigate(replace)로 교체
 (URL만 바뀌고 화면이 전환되지 않던 문제 해소)
- 템플릿 목록 조회 실패 시 카카오 사유를 빨간 배너로 표시
- report_url 데이터소스 if 조건으로 templates 탭 불필요 재조회 억제
This commit is contained in:
chym1217 committed 2026-07-28 12:36:25 +09:00
1 parent 17a95bb556
commit fc00e30fc4
7 files changed
+179 -17

No files matched your search

@@ -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": "새로고침",
@@ -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" }
@@ -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 를 조회합니다.
*
@@ -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
# 설치·활성화 (백엔드)