From 0a6a8d6968a36621e8f75169a483bb03bb674a0f Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 15:48:45 +0900 Subject: [PATCH] =?UTF-8?q?fix(message=5Fbizppurio):=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=205002=20=EC=96=B8=EC=96=B4=ED=8C=A9=20?= =?UTF-8?q?=EB=88=84=EB=9D=BD=20+=20=EC=95=8C=EB=A6=BC=ED=86=A1=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A0=80=EC=9E=A5=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=EA=B2=80=EC=A6=9D=20=EB=B6=80=EC=9E=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 준비 최종 검토 중 문제코드 처리 대조표를 재검증하며 발견한 결함 2건. - ResultCodeResolver 가 5002(요청 과다)를 재시도 대상으로 분류하지만 ko/en/ja 언어팩 어디에도 사유가 없어 코드값만 노출되던 결함 수정 - 알림톡 템플릿 연결 저장이 승인 상태(RDY/ACT) 재검증 없이 그대로 upsert되어, 화면 드롭다운(승인 템플릿만 노출)을 우회한 API 직접 호출 시 미승인 템플릿도 저장 가능했던 결함 수정 — bind 에 카카오 승인 목록 대조 추가, 조회 자체 실패 시에도 안전측으로 저장 거부 --- app/Notifications/GenericNotification.php | 32 ++------- .../CHANGELOG.md | 2 +- .../backend/ja/messages.php | 1 + .../backend/ja/result_codes.php | 1 + .../sirsoft-message_bizppurio/CHANGELOG.md | 4 +- .../docs/api/notification-bindings.md | 3 +- .../lang/en/messages.php | 1 + .../lang/en/result_codes.php | 1 + .../lang/ko/messages.php | 1 + .../lang/ko/result_codes.php | 1 + .../Admin/NotificationBindingController.php | 20 +++--- .../Services/NotificationBindingService.php | 33 ++++++++- .../NotificationBindingEndpointTest.php | 60 +++++++++++++++++ .../NotificationBindingServiceTest.php | 67 +++++++++++++++++-- .../Unit/Services/ResultCodeResolverTest.php | 13 ++++ .../GenericNotificationViaTest.php | 2 + 16 files changed, 195 insertions(+), 47 deletions(-) diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php index d21be8f4..9e6ad309 100644 --- a/app/Notifications/GenericNotification.php +++ b/app/Notifications/GenericNotification.php @@ -67,11 +67,13 @@ class GenericNotification extends BaseNotification // 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인 if ($this->channel !== null) { try { + $channelService = app(NotificationChannelService::class); + // 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음). // 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만, // 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지 // 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님). - if (! $this->isChannelAvailable($this->channel)) { + if (! $channelService->isChannelAvailable($this->channel)) { return []; } @@ -85,7 +87,6 @@ class GenericNotification extends BaseNotification } // 0단계: 확장 단위 채널 전역 활성 여부 확인 - $channelService = app(NotificationChannelService::class); if (! $channelService->isChannelEnabledForExtension( $this->extensionType, $this->extensionIdentifier, @@ -136,7 +137,7 @@ class GenericNotification extends BaseNotification foreach ($channels as $channel) { // 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이 // 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책). - if (! $this->isChannelAvailable($channel)) { + if (! $channelService->isChannelAvailable($channel)) { continue; } @@ -208,31 +209,6 @@ class GenericNotification extends BaseNotification } } - /** - * 채널이 현재 사용 가능한(살아 있는) 채널 목록에 있는지 확인합니다. - * - * getAvailableChannels() 는 코어 기본 채널(mail/database)과 활성 확장이 filter 훅으로 추가한 - * 채널만 담는다. 확장이 비활성/삭제되면 그 채널이 이 목록에서 자동으로 빠지므로, 특정 확장 - * 이름을 알지 못해도 "죽은 채널"을 판별할 수 있다. 조회 실패 시(부팅 초기 등) 안전측으로 true - * 를 반환해 기존 게이트(확장 토글·readiness)가 판정하도록 위임한다. - * - * @param string $channel 채널 식별자 - * @return bool 사용 가능하면 true - */ - private function isChannelAvailable(string $channel): bool - { - try { - $availableIds = array_column( - app(NotificationChannelService::class)->getAvailableChannels(), - 'id' - ); - - return in_array($channel, $availableIds, true); - } catch (\Throwable $e) { - return true; - } - } - /** * 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다. * diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index c48a6083..57ad5f67 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -8,4 +8,4 @@ ### Added -- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index ca0770ef..5650439c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -57,6 +57,7 @@ return [ 'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。', 'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。', 'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。', + 'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)', ], 'send_skipped' => [ 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php index e16ebad7..8309d4c5 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -24,6 +24,7 @@ return [ '4430' => 'スパム', '4431' => '送信制限受信拒否(スパム)', '4443' => 'スパムブロック', + '5002' => 'リクエストが多すぎます', '5003' => '一時的な送信エラー', '5004' => '一時的な送信エラー', '5005' => '一時的な送信エラー', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index f18b6e25..eb97c84f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,6 +9,6 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. -- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. +- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다. +- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md index 903f5225..4ea771f9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -144,12 +144,13 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) | **설명** -알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다. +알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다. ### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index d6281577..31d107c1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -80,6 +80,7 @@ return [ 'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.', 'kakao_request_failed' => 'The Kakao management API request failed.', 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', + 'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)', ], // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php index 48b0e9f0..7ebca552 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -29,6 +29,7 @@ return [ '3010' => 'IP not in allowlist', '3011' => 'Unknown error (Bizppurio)', '3013' => 'Message not completed', + '5002' => 'Too many requests', '5003' => 'Temporary delivery error', '5004' => 'Temporary delivery error', '5005' => 'Temporary delivery error', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index df9b94eb..ebb6ae0a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -80,6 +80,7 @@ return [ 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', + 'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)', ], // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php index 63144999..cfd19c83 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -32,6 +32,7 @@ return [ '3010' => '접속 허용 IP 불일치', '3011' => '알 수 없는 오류(비즈뿌리오)', '3013' => '완료 처리되지 않은 메시지', + '5002' => '요청 과다', '5003' => '일시적 전송 오류', '5004' => '일시적 전송 오류', '5005' => '일시적 전송 오류', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php index 3294a69e..b74b9e47 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php @@ -83,15 +83,17 @@ class NotificationBindingController extends AdminBaseController { $validated = $request->validated(); - $this->service->applyFromTemplateSave( - $validated['notification_type'], - $validated['template_code'] ?? null, - $validated['template_name'] ?? null, - (bool) ($validated['fallback_sms_enabled'] ?? false), - ); + return $this->guard(function () use ($validated) { + $this->service->applyFromTemplateSave( + $validated['notification_type'], + $validated['template_code'] ?? null, + $validated['template_name'] ?? null, + (bool) ($validated['fallback_sms_enabled'] ?? false), + ); - return ResponseHelper::success('messages.binding.saved', [ - 'bindings' => $this->service->all(), - ]); + return ResponseHelper::success('messages.binding.saved', [ + 'bindings' => $this->service->all(), + ]); + }); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php index 6bdf291f..f3fd3a19 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -130,20 +130,49 @@ class NotificationBindingService /** * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장). * + * 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만 + * 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀). + * 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로 + * 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다. + * * @param string $notificationType 코어 notification_definitions.type * @param array $data template_code / template_name / fallback_sms_enabled * @return BizppurioNotificationBinding 저장된 연동 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 */ public function bind(string $notificationType, array $data): BizppurioNotificationBinding { + $templateCode = (string) $data['template_code']; + + $this->assertSendable($templateCode); + return $this->bindings->upsert($notificationType, self::CHANNEL, [ - 'template_code' => (string) $data['template_code'], + 'template_code' => $templateCode, 'template_name' => (string) $data['template_name'], 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false), 'is_active' => true, ]); } + /** + * 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다. + * + * @param string $templateCode 검증할 카카오 템플릿 코드 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 + */ + private function assertSendable(string $templateCode): void + { + $sendableCodes = array_column($this->approvedTemplates(), 'template_code'); + + if (! in_array($templateCode, $sendableCodes, true)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]), + ); + } + } + /** * 알림의 알림톡 연동을 해제(삭제)합니다. * @@ -184,6 +213,8 @@ class NotificationBindingService * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용) * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생) */ public function applyFromTemplateSave( string $notificationType, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php index 2b58b5d4..106c2050 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php @@ -5,7 +5,10 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use Mockery; +use Mockery\MockInterface; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** @@ -20,6 +23,37 @@ class NotificationBindingEndpointTest extends PluginTestCase { private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'; + /** + * AlimtalkTemplateService mock 을 컨테이너에 바인딩하고 반환한다. + * + * bind() 가 저장 전 카카오 승인 목록을 조회하므로(회귀 방지 재검증), 실제 kapi 호출 없이 + * 저장 경로를 검증하려면 이 mock 으로 승인 템플릿 목록을 지정해야 한다. + */ + private function mockTemplateService(): MockInterface + { + $mock = Mockery::mock(AlimtalkTemplateService::class); + $this->app->instance(AlimtalkTemplateService::class, $mock); + + return $mock; + } + + /** + * 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다. + * + * @param array $codes 승인 처리할 template_code 목록 + */ + private function stubApprovedTemplates(array $codes): void + { + $templates = array_map( + fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], + $codes, + ); + + $this->mockTemplateService() + ->shouldReceive('list') + ->andReturn(['templates' => $templates, 'pagination' => []]); + } + /** * 지정 권한 식별자들을 가진 admin 사용자를 만듭니다. * @@ -126,6 +160,7 @@ class NotificationBindingEndpointTest extends PluginTestCase public function test_manage_권한으로_연동을_즉시_저장한다(): void { + $this->stubApprovedTemplates(['TW_1236']); $admin = $this->adminWith([ 'sirsoft-message_bizppurio.messaging.view', 'sirsoft-message_bizppurio.messaging.manage', @@ -147,6 +182,29 @@ class NotificationBindingEndpointTest extends PluginTestCase ]); } + public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void + { + // 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접 + // 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단. + $this->stubApprovedTemplates(['TW_OTHER']); + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + 'template_name' => '미승인', + ]); + + $response->assertStatus(422); + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + ]); + } + public function test_빈_코드로_저장하면_연동을_해제한다(): void { BizppurioNotificationBinding::create([ @@ -163,6 +221,7 @@ class NotificationBindingEndpointTest extends PluginTestCase 'sirsoft-message_bizppurio.messaging.manage', ]); + // 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요. $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ 'notification_type' => 'welcome', 'template_code' => '', @@ -182,6 +241,7 @@ class NotificationBindingEndpointTest extends PluginTestCase 'sirsoft-message_bizppurio.messaging.manage', ]); + // FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요. $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ 'template_code' => 'TW_1236', ]); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php index 5bbd6efd..b1f71f6b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php @@ -162,9 +162,22 @@ class NotificationBindingServiceTest extends PluginTestCase $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.'); } + /** + * bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture). + * + * @param array $approvedCodes 승인(발송 가능) 처리할 template_code 목록 + */ + private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService + { + return $this->makeService(array_map( + fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], + $approvedCodes, + )); + } + public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1236']); $service->bind('welcome', [ 'template_code' => 'TW_1236', @@ -189,7 +202,7 @@ class NotificationBindingServiceTest extends PluginTestCase public function test_bind는_같은_알림에_대해_갱신한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']); $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']); $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']); @@ -201,9 +214,53 @@ class NotificationBindingServiceTest extends PluginTestCase ]); } + public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void + { + // 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를 + // 직접 호출하면 미승인 템플릿도 저장되던 결함. + $service = $this->makeServiceWithApprovedCodes(['TW_OTHER']); + + $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); + + try { + $service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']); + } finally { + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + ]); + } + } + + public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void + { + // 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지). + $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class); + $throwingTemplates->shouldReceive('list') + ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정')); + + $service = new NotificationBindingService( + new BizppurioNotificationBindingRepository, + $throwingTemplates, + $this->fakeKakaoContent(), + ); + + $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); + + try { + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']); + } finally { + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1', + ]); + } + } + public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void { - $this->makeService()->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); + $this->makeServiceWithApprovedCodes(['TW_9']) + ->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); $this->assertDatabaseHas('bizppurio_notification_bindings', [ 'notification_type' => 'welcome', @@ -215,10 +272,10 @@ class NotificationBindingServiceTest extends PluginTestCase public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1']); $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']); - // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 + // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님) $service->applyFromTemplateSave('welcome', '', null, false); $this->assertDatabaseMissing('bizppurio_notification_bindings', [ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php index 4da170bf..35fb3c85 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -64,6 +64,19 @@ class ResultCodeResolverTest extends PluginTestCase } } + /** + * 재시도 대상으로 분류된 코드는 전부 사유가 lang 에 정의돼 있어야 한다(코드값만 노출 금지). + * + * 회귀: RETRYABLE_CODES 에 5002 가 분류돼 있었으나 lang/{ko,en}/result_codes.php 와 ja + * 번들에 5002 키가 누락되어 label('5002') 가 사유 없이 코드만 반환하던 결함. + */ + public function test_all_retryable_codes_have_reason(): void + { + foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { + $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락"); + } + } + /** * 일시오류 코드는 Retry. * diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index d544af73..76663499 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -181,6 +181,8 @@ class GenericNotificationViaTest extends TestCase }); $mock->shouldReceive('getAvailableChannels') ->andReturn(array_map(static fn (string $id) => ['id' => $id], $availableIds)); + $mock->shouldReceive('isChannelAvailable') + ->andReturnUsing(fn (string $channel) => in_array($channel, $availableIds, true)); $this->app->instance(NotificationChannelService::class, $mock); }