diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 611c7ec6..1997c91c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -34,6 +34,7 @@ - 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. - 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. - 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. +- 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 66f77757..f901b492 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -76,6 +76,15 @@ return [ 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', ], + // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log) + 'send_skipped' => [ + 'alimtalk_binding_missing' => 'Skipped sending: no alimtalk template is bound. (notification type: :type)', + 'alimtalk_kakao_content_unavailable' => 'Skipped sending: failed to fetch the approved Kakao template content. (notification type: :type)', + 'sms_template_missing' => 'Skipped sending: no SMS template found. (notification type: :type)', + 'recipient_phone_missing' => 'Skipped sending: recipient phone number is missing. (notification type: :type)', + 'message_body_empty' => 'Skipped sending: message body is empty. (notification type: :type)', + ], + // Notification-to-alimtalk template binding (NotificationBindingController responses) 'binding' => [ 'saved' => 'Alimtalk binding saved.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 4144a4e9..a1fc0b42 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -76,6 +76,15 @@ return [ 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', ], + // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) + 'send_skipped' => [ + 'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)', + 'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)', + 'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)', + ], + // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) 'binding' => [ 'saved' => '알림톡 연동을 저장했습니다.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php new file mode 100644 index 00000000..b52dde93 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php @@ -0,0 +1,18 @@ +definitionService->resolve($type)?->getLocalizedName(); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + /** * 알림을 카카오 알림톡으로 발송합니다. * @@ -95,11 +120,13 @@ class AlimtalkChannelDriver $type = $notification->getType(); // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송) + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value); if ($binding === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 연결된 템플릿 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로, @@ -107,29 +134,26 @@ class AlimtalkChannelDriver // 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다. $kakaoContent = $this->kakaoContent->resolve($binding->template_code); if ($kakaoContent === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 카카오 템플릿 내용 조회 실패', [ - 'type' => $type, - 'template_code' => $binding->template_code, - ]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)]) + ); } // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) $to = $this->resolvePhone($notifiable, $notification->getData()); if ($to === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 전화번호 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. $mapped = $this->payloadMapper->map($kakaoContent, $notification->getData()); $message = (string) ($mapped['message'] ?? ''); if (trim($message) === '') { - Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) + ); } // 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index a7b92c9d..6981b34c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -7,14 +7,15 @@ namespace Plugins\Sirsoft\MessageBizppurio\Services; use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; @@ -26,13 +27,19 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep * (`send($notifiable, Notification $notification)`)을 구현한다. * * 처리 흐름(계획서 Phase 3 ②): - * 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 skip(Phase 6 에서 - * 3영역 기본 body 시드 예정 — A안 확정). + * 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 + * NotificationSendSkippedException(Phase 6 에서 3영역 기본 body 시드 예정 — A안 확정). * 2. 전화번호 해석: 회원=Notifiable->mobile, 비회원=알림 data 의 _recipient_phone * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1). * 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 → * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임). * + * 1~2단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라 + * NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의 + * catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 + * "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로 + * 기록하는 문제 — 이슈 #28). + * * 발송 이력(bizppurio_dispatches) 영속화는 Phase 4(테이블 신설)에서 이 흐름에 연결한다. * Phase 3 은 refkey 생성 + payload 조립 + Job 위임까지 담당한다. */ @@ -46,6 +53,7 @@ class SmsChannelDriver /** * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve + * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용) * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) @@ -54,6 +62,7 @@ class SmsChannelDriver */ public function __construct( private readonly NotificationTemplateService $templateService, + private readonly NotificationDefinitionService $definitionService, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, @@ -61,6 +70,25 @@ class SmsChannelDriver private readonly PluginSettingsService $pluginSettings, ) {} + /** + * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용). + * + * 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백). + * + * @param string $type 알림 유형 코드값 (welcome 등) + * @return string 사람이 읽는 이름 또는 코드값 + */ + private function resolveTypeLabel(string $type): string + { + try { + $label = $this->definitionService->resolve($type)?->getLocalizedName(); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + /** * 알림을 문자(SMS/LMS)로 발송합니다. * @@ -79,19 +107,21 @@ class SmsChannelDriver $type = $notification->getType(); // 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드) + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. $template = $this->templateService->resolve($type, DispatchChannel::Sms->value); if ($template === null || ! $template->is_active) { - Log::info('비즈뿌리오 SMS 발송 skip — sms 템플릿 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.sms_template_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) $to = $this->resolvePhone($notifiable, $notification->getData()); if ($to === null) { - Log::info('비즈뿌리오 SMS 발송 skip — 전화번호 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 3. 본문 렌더 (변수 치환) @@ -99,9 +129,9 @@ class SmsChannelDriver $rendered = $template->replaceVariables($notification->getData(), $locale); $message = (string) ($rendered['body'] ?? ''); if (trim($message) === '') { - Log::info('비즈뿌리오 SMS 발송 skip — 본문 비어 있음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) + ); } // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index c181392f..563b8297 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -6,11 +6,13 @@ use App\Models\NotificationTemplate; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; @@ -125,8 +127,13 @@ class AlimtalkChannelDriverTest extends PluginTestCase ->with('sirsoft-message_bizppurio', 'is_test_mode', true) ->andReturn($isTestMode); + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn(null); + return new AlimtalkChannelDriver( $templateService, + $definitionService, $this->fakeBindings($binding), $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, @@ -173,28 +180,94 @@ class AlimtalkChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_연결된_템플릿이_없으면_발송하지_않는다(): void + public function test_연결된_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding = null → 미연결 → skip - $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + // binding = null → 미연결 → NotificationSendSkippedException(코어가 실패로 기록하도록) + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); - $this->assertDatabaseCount('bizppurio_dispatches', 0); + try { + $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } } - public function test_카카오_템플릿_내용_조회_실패시_발송하지_않는다(): void + public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 → skip - $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) - ->send($member, $this->notification()); + $definition = Mockery::mock(\App\Models\NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); - Bus::assertNotDispatched(SendMessageJob::class); + $definitionService = Mockery::mock(\App\Services\NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); + + $templateService = Mockery::mock(\App\Services\NotificationTemplateService::class); + $templateService->shouldReceive('resolve')->with(Mockery::any(), 'alimtalk')->andReturn(null); + + $pluginSettings = Mockery::mock(\App\Services\PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(true); + + $driver = new AlimtalkChannelDriver( + $templateService, + $definitionService, + $this->fakeBindings(null), + $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + $this->fakeKakaoContent($this->kakaoContent()), + new AlimtalkPayloadMapper, + $pluginSettings, + ); + + try { + $driver->send($member, $this->notification()); + $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); + } catch (NotificationSendSkippedException $e) { + $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + } + } + + public function test_카카오_템플릿_내용_조회_실패시_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 + // → NotificationSendSkippedException(코어가 실패로 기록하도록) + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + public function test_카카오_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 카카오 templateContent 가 치환 후에도 빈 문자열이면 발송 불가 + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver( + $this->fakeTemplate(), + $this->binding(), + kakao: ['templateCode' => 'TW_1234', 'templateContent' => ' '], + )->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void @@ -400,15 +473,19 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.'); } - public function test_전화번호가_없으면_발송하지_않는다(): void + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); - $this->makeDriver($this->fakeTemplate(), $this->binding()) - ->send($guest, $this->notification(['name' => '홍길동'])); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate(), $this->binding()) + ->send($guest, $this->notification(['name' => '홍길동'])); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_generic_notification이_아니면_무시한다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index c68a3393..ec1fe165 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -6,11 +6,13 @@ use App\Models\NotificationTemplate; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; @@ -65,8 +67,13 @@ class SmsChannelDriverTest extends PluginTestCase ->with('sirsoft-message_bizppurio', 'is_test_mode', true) ->andReturn($isTestMode); + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn(null); + return new SmsChannelDriver( $templateService, + $definitionService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, @@ -107,14 +114,54 @@ class SmsChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_템플릿이_없으면_발송하지_않는다(): void + public function test_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver(null)->send($member, $this->notification()); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver(null)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $definition = Mockery::mock(\App\Models\NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); + + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); + + $templateService = Mockery::mock(NotificationTemplateService::class); + $templateService->shouldReceive('resolve')->with(Mockery::any(), 'sms')->andReturn(null); + + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(true); + + $driver = new SmsChannelDriver( + $templateService, + $definitionService, + new SmsTypeResolver, + $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + $pluginSettings, + ); + + try { + $driver->send($member, $this->notification()); + $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); + } catch (NotificationSendSkippedException $e) { + $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + } } public function test_회원은_mobile로_발송한다(): void @@ -211,21 +258,29 @@ class SmsChannelDriverTest extends PluginTestCase Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // 템플릿 없음 → skip → 이력도 없어야 함 - $this->makeDriver(null)->send($member, $this->notification()); + // 템플릿 없음 → NotificationSendSkippedException → 이력도 없어야 함 + $this->expectException(NotificationSendSkippedException::class); - $this->assertDatabaseCount('bizppurio_dispatches', 0); + try { + $this->makeDriver(null)->send($member, $this->notification()); + } finally { + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } } - public function test_전화번호가_없으면_발송하지_않는다(): void + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); // data 에 전화번호 없음 + 게스트라 mobile 속성 없음 - $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void @@ -248,15 +303,19 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.'); } - public function test_본문이_비어있으면_발송하지_않는다(): void + public function test_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) - ->send($member, $this->notification()); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_generic_notification이_아니면_무시한다(): void