fix(message_bizppurio): 발송 건너뜀이 코어 이력에 성공으로 오표시되던 문제 수정

SMS 템플릿 없음·알림톡 카카오 템플릿 미연결 등으로 발송 자체를 시도하지
못한 경우, 채널 드라이버가 조용히 return하면 코어가 예외 미발생을
성공으로 착각해 발송 이력에 "성공"으로 잘못 기록했다. 드라이버가
NotificationSendSkippedException을 던지도록 바꿔, 코어의 기존
실패 처리 경로(channel_send_failed 훅)를 그대로 태워 "실패"로 정확히
기록되게 한다. 코어 파일은 수정하지 않는다.

실패 사유 메시지의 알림 유형도 내부 코드값(welcome) 대신 사람이 읽는
이름(회원가입 환영)으로 표시하도록 함께 개선했다.
This commit is contained in:
chym1217
2026-07-28 12:40:17 +09:00
parent efe1a81cb8
commit e8784854aa
8 changed files with 290 additions and 63 deletions
@@ -34,6 +34,7 @@
- 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다.
- 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다.
- 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다.
- 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다.
### Changed
@@ -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.',
@@ -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' => '알림톡 연동을 저장했습니다.',
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Exceptions;
use RuntimeException;
/**
* 발송 준비 미비로 인한 발송 건너뜀 예외.
*
* 알림톡 템플릿 미연결, SMS 템플릿 없음, 수신자 전화번호 없음 등 비즈뿌리오
* API 호출 자체를 시도하지 못하고 채널 드라이버 단계에서 건너뛴 경우 던진다.
* 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이를
* core.notification.channel_send_failed 훅으로 연결해, 발송 이력 화면에
* "성공"이 아닌 "실패"로 정확히 기록되게 한다.
*/
class NotificationSendSkippedException extends RuntimeException {}
@@ -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;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
@@ -35,18 +36,21 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio
* 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수).
*
* 처리 흐름:
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 skip(연결 안 된 알림은 알림톡 미발송).
* 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 skip.
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 NotificationSendSkippedException.
* 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 동일 예외.
* 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 skip.
* 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 동일 예외.
* 5. refkey 생성 → payload 조립(버튼·요소는 extra, 대체발송 ON 시 resend/recontent 병합) →
* 이력 pending → SendMessageJob 위임.
*
* 카카오 조회 실패(고아 template_code·장애·rate limit)는 알림톡 발송을 skip 하고 로그만 남긴다.
* 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송은 트리거되지 않는다 — SMS 대체는
* 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤 비즈뿌리오 측 발송 실패(수신 거부·미가입
* 등)에만 작동한다(fallback_sms_enabled ON 시). 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로
* webhook·Job 이 이력에 기록한다(Phase 4).
* 1~4 단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라
* NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의
* catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌
* "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로
* 기록하는 문제 — 이슈 #28). 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송도
* 트리거되지 않는다 — SMS 대체는 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤
* 비즈뿌리오 측 발송 실패(수신 거부·미가입 등)에만 작동한다(fallback_sms_enabled ON 시).
* 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로 webhook·Job 이 이력에 기록한다(Phase 4).
*/
class AlimtalkChannelDriver
{
@@ -58,6 +62,7 @@ class AlimtalkChannelDriver
/**
* @param NotificationTemplateService $templateService SMS 대체발송 본문(코어 alimtalk 템플릿) resolve
* @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용)
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
@@ -68,6 +73,7 @@ class AlimtalkChannelDriver
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
private readonly NotificationDefinitionService $definitionService,
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
@@ -77,6 +83,25 @@ class AlimtalkChannelDriver
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;
}
}
/**
* 알림을 카카오 알림톡으로 발송합니다.
*
@@ -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 병합)
@@ -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 조립
@@ -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
@@ -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