diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index b8e87d64..17684dd5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -25,10 +25,17 @@ - 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. - 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. - 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다. -- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. +- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. 버튼 URL이 로그인·비밀번호 재설정 등 완전한 주소 변수와 결합되는 경우에도 주소가 겹치지 않도록 처리합니다. - 환경설정에 "알림톡 내용 캐시 시간(분)"과 "캐시 초기화" 버튼을 추가했습니다. 카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용하며(기본 60분, 0으로 두면 발송할 때마다 최신 내용을 가져옵니다), 카카오에서 템플릿을 방금 수정한 경우 [캐시 초기화]로 다음 발송부터 최신 내용이 즉시 반영되도록 할 수 있습니다. - 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다. - 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.) - 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다. - 검수 모드 설정에 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장한다는 안내를 함께 표시합니다. - 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. +- 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. +- 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. + +### Changed + +- 환경설정·발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일했습니다. 실제 동작 변화는 없습니다. +- 알림 설정 화면에서 발송 설정이 미완료된 상태에서도 검수 모드 안내가 함께 표시되도록 했습니다. 두 안내 문구 사이 여백도 정리했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php index fd029852..49a4ec70 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -32,6 +32,7 @@ return new class extends Migration $table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null') ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete(); $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)'); + $table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)'); $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); $table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다'); $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)'); @@ -39,6 +40,7 @@ return new class extends Migration $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)'); $table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)'); $table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)'); + $table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)'); $table->timestamp('sent_at')->nullable()->comment('발송 시각'); $table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)'); $table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드'); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php index 25e3ce49..e22d555f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php @@ -25,7 +25,7 @@ return new class extends Migration $table->bigIncrements('id')->comment('연결 설정 PK'); $table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)'); $table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)'); - $table->string('template_code', 30)->comment('연결한 카카오 알림톡 템플릿 코드'); + $table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드'); $table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)'); $table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)'); $table->boolean('is_active')->default(true)->comment('연동 활성 여부'); 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 253fb224..903f5225 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -85,7 +85,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | notification_type | body | string | 예 | max 100 | | -| template_code | body | string | 아니오 | max 30 | | +| template_code | body | string | 아니오 | max 50 | | | template_name | body | string | 아니오 | max 255 | template 이름 (식별자) | | fallback_sms_enabled | body | boolean | 아니오 | — | | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 761931e6..ddd6f56b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,9 +5,12 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use App\Extension\Helpers\NotificationSyncHelper; use App\Extension\HookListenerRegistrar; use App\Extension\ModuleManager; +use App\Models\NotificationDefinition; use Database\Seeders\NotificationDefinitionSeeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener; @@ -86,6 +89,14 @@ class Plugin extends AbstractPlugin * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로 * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리. * + * SeedChannelTemplatesListener 의 HookListenerRegistrar 등록 이력 캐시를 함께 지운다 — + * activate() 가 register() 로 등록한 뒤(process-wide idempotency 캐시, source::class 키), + * deactivate/uninstall 후 재활성화해도 이 캐시가 "이미 등록됨"으로 남아있으면 재등록이 + * 조용히 skip 되어 재시딩 시 sms·alimtalk 채널이 다시 붙지 않는다(회귀). 코어 + * HookListenerRegistrar 에는 특정 키 1건만 지우는 API 가 없고(clear() 는 전체 초기화라 + * 다른 확장의 등록 상태까지 날아감), 코어 수정 없이 이 플러그인 내부에서만 해결하기 + * 위해 리플렉션으로 private static $registered 캐시에서 이 키만 직접 제거한다. + * * @return bool 비활성화 성공 여부 */ public function deactivate(): bool @@ -96,21 +107,93 @@ class Plugin extends AbstractPlugin currentSlugs: [], ); + $this->forgetSeedChannelTemplatesListenerRegistration(); + return true; } /** - * 플러그인 제거 — 메뉴 잔재 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * HookListenerRegistrar::$registered 캐시에서 SeedChannelTemplatesListener 등록 이력만 제거합니다. + * + * 코어 HookListenerRegistrar 는 개별 키 삭제 API 를 제공하지 않으므로(clear() 는 전체 + * 초기화), 코어를 수정하지 않고 이 캐시를 조작하기 위해 리플렉션을 사용한다. 실패해도 + * (리플렉션 예외 등) 치명적이지 않으므로 조용히 무시한다 — 최악의 경우 이번 재활성화만 + * 채널 재시딩이 안 붙고, 운영자가 다시 활성화하면 정상화된다. + */ + private function forgetSeedChannelTemplatesListenerRegistration(): void + { + try { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $registered = $prop->getValue(); + $key = $this->getIdentifier().'::'.SeedChannelTemplatesListener::class; + unset($registered[$key]); + $prop->setValue(null, $registered); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] SeedChannelTemplatesListener 등록 캐시 초기화 실패', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 플러그인 제거 — 메뉴 잔재 안전망(정상 흐름은 deactivate 가 먼저 처리) + sms·alimtalk 채널 잔재 정리. + * + * SeedChannelTemplatesListener 가 코어/게시판/이커머스 소유 알림 정의에 필터 훅으로 끼워넣은 + * sms·alimtalk template 은, 그 정의가 우리 소유가 아니므로 PluginManager 의 범용 알림 정의 + * 정리(cleanupStaleDefinitions('plugin', ...))에 걸리지 않는다. 코어는 이 채널의 존재나 + * 소유자를 몰라야 하므로, 코어를 수정하지 않고 우리가 직접 우리 채널 상수 기준으로 정리한다. + * + * "데이터도 함께 삭제" 옵션과 무관하게 항상 정리한다 — sms·alimtalk 은 이 플러그인 없이는 + * 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며 + * 자동으로 복원된다. * * @return bool 제거 성공 여부 */ public function uninstall(): bool { $this->deactivate(); + $this->cleanupChannelContributions(); return true; } + /** + * sms·alimtalk 채널 template 및 definitions.channels 배열에서 우리 채널 잔재를 정리합니다. + * + * 삭제 대상 채널은 우리가 이미 아는 채널 상수(RegisterNotificationChannelsListener::CHANNEL_IDS) + * 이므로, 각 정의가 원래 어떤 채널로 구성돼 있었는지 사전 지식 없이 "현재 DB 채널 목록 − + * 우리 채널" 만으로 남길 목록을 계산할 수 있다. template 삭제는 코어 공개 메서드 + * NotificationSyncHelper::cleanupStaleTemplates() 를 그대로 사용해 로깅·모델 이벤트 등 + * 코어 도메인 규칙을 그대로 따른다. + */ + private function cleanupChannelContributions(): void + { + $myChannels = RegisterNotificationChannelsListener::CHANNEL_IDS; + $helper = app(NotificationSyncHelper::class); + + $definitionIds = DB::table('notification_templates') + ->whereIn('channel', $myChannels) + ->distinct() + ->pluck('definition_id'); + + foreach ($definitionIds as $definitionId) { + $current = DB::table('notification_templates') + ->where('definition_id', $definitionId) + ->pluck('channel') + ->all(); + $keep = array_values(array_diff($current, $myChannels)); + + $helper->cleanupStaleTemplates($definitionId, $keep); + + $definition = NotificationDefinition::find($definitionId); + if ($definition) { + $definition->channels = $keep; + $definition->save(); + } + } + } + /** * 플러그인 권한 목록 반환 (계층 구조) * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json index 531ad00e..4e294559 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -72,9 +72,9 @@ "type": "basic", "name": "Span", "props": { - "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" }, - "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" }, { "type": "basic", @@ -138,9 +138,9 @@ "type": "basic", "name": "Span", "props": { - "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" }, - "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" }, { "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index 9aa02141..a8b4fc52 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -49,7 +49,7 @@ "name": "Div", "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", "props": { - "className": "mt-4 mb-3" + "className": "mt-4 mb-3 space-y-2" }, "children": [ { @@ -111,7 +111,7 @@ "id": "bizppurio_banner_test_mode", "type": "basic", "name": "Div", - "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready !== false && (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", "props": { "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx index d4d196ce..6616bbb8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -1,3 +1,6 @@ +// e2e:allow 검수 라벨 표시(is_test_mode)는 Chrome MCP로 실제 회원가입→SMS 발송→화면 렌더까지 +// 실측 검증 완료(2026-07-24). 정식 E2E는 비즈뿌리오 자격증명·채널 활성화·webhook 등 발송 인프라 +// 의존이 커서 이번 변경 범위를 벗어나며, 별도 계획(plan-e2e-tests)에서 다룰 예정. /** * 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2) * @@ -187,4 +190,44 @@ describe('A-2 결과 컬럼 주입 — 렌더', () => { expect(screen.queryByText('-')).not.toBeInTheDocument(); utils.cleanup(); }); + + it('검수 모드 발송 건은 상태 라벨(발송중) 대신 검수 라벨을 렌더한다', async () => { + // is_test_mode=true 이면 status='sent'(발송중)이어도 검수 라벨로 대체 표시한다(PO 확정 — + // "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체). + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([4]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 4: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: true } } } }, + }); + await utils.render(); + // $t('key') 는 이 렌더 환경에서 번역 실패 시 원문 키를 그대로 반환한다($t: 와 동일 폴백). + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).toBeInTheDocument(); + expect(screen.queryByText('발송중')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('운영 모드 발송 건은 검수 라벨 없이 기존 상태 라벨을 그대로 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([5]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 5: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: false } } } }, + }); + await utils.render(); + expect(screen.getByText('발송중')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('is_test_mode 필드가 없는 과거 이력은 검수 라벨 없이 기존 라벨을 렌더한다', async () => { + // 컬럼 신설 이전 이력(is_test_mode 미포함)도 undefined === true 가 false 이므로 안전하게 기존 라벨. + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([6]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 6: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('성공 (4100)')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index a3637a10..f68e83cf 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -1,3 +1,5 @@ +// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면 +// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸. /** * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2) * @@ -75,6 +77,12 @@ describe('binding UI — 상태 배너 + 안내 박스', () => { expect(cond).toContain('is_test_mode === true'); }); + it('빨강(설정 미완료)·노랑(검수 모드) 배너가 동시에 뜰 때 간격이 있다(회귀: 두 배너가 붙어 보이던 문제)', () => { + const banner = findById(bannerRoot, 'bizppurio_status_banner'); + const className = (banner as { props?: { className?: string } }).props?.className ?? ''; + expect(className).toMatch(/space-y-\d/); + }); + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); expect(raw).toContain('banner.not_ready'); @@ -82,6 +90,16 @@ describe('binding UI — 상태 배너 + 안내 박스', () => { expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); }); + it('검수 모드 배너는 readiness 충족 여부와 무관하게 is_test_mode 만으로 노출된다', () => { + // readiness 실패(예: 알림톡 API 키 미설정) + 검수 모드가 동시에 참인 상황에서도 + // 검수 안내가 가려지면 안 된다(회귀: 과거 readiness?.ready !== false 조건이 배너를 숨겼음). + const banner = findById(bannerRoot, 'bizppurio_banner_test_mode'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).not.toContain('readiness'); + expect(cond).toContain('is_test_mode === true'); + }); + it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => { const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); expect(guide).toBeTruthy(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index db034ab3..70751cd3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -9,12 +9,12 @@ "save_success": "Settings saved.", "save_failed": "Failed to save settings.", "test_mode": { - "label": "Test Mode", - "hint": "In test mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", + "label": "Inspection Mode", + "hint": "In inspection mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", "account_notice": "We recommend using separate Bizppurio accounts for inspection and production." }, "live_mode_warning_title": "Sending in production", - "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", + "live_mode_warning_body": "Inspection mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep inspection mode on during inspection.", "sections": { "api": { "title": "API Integration", @@ -105,10 +105,11 @@ "banner": { "not_ready": "Setup required for delivery is incomplete.", "setup_action": "Set up", - "test_mode": "Test mode — messages are not actually sent." + "test_mode": "Inspection mode — messages are not actually sent." }, "dispatch_result": { "column_header": "SMS/Alimtalk Result", + "inspection_label": "Inspection", "low_balance": "Low balance", "fallback": "SMS fallback {status}", "detail_title": "SMS/Alimtalk delivery result", @@ -128,7 +129,7 @@ "missing_label": "Missing", "api_key_missing": "Kakao management API key", "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." + "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off inspection mode and using templates approved by Kakao." }, "list_error": { "title": "Failed to load the template list" diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index a46b0e9c..610edbc9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -105,10 +105,11 @@ "banner": { "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", "setup_action": "설정하기", - "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + "test_mode": "검수 모드입니다 — 실제 발송은 이뤄지지 않습니다." }, "dispatch_result": { "column_header": "문자·알림톡 결과", + "inspection_label": "검수", "low_balance": "잔액 부족", "fallback": "SMS 대체발송 {status}", "detail_title": "문자·알림톡 발송 결과", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php index 3ca1a11c..c9cfd3a7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -33,7 +33,7 @@ class StoreNotificationBindingRequest extends FormRequest { return [ 'notification_type' => ['required', 'string', 'max:100'], - 'template_code' => ['nullable', 'string', 'max:30'], + 'template_code' => ['nullable', 'string', 'max:50'], 'template_name' => ['nullable', 'string', 'max:255'], 'fallback_sms_enabled' => ['sometimes', 'boolean'], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 85c61807..95983d24 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -38,8 +38,8 @@ class RegisterNotificationChannelsListener implements HookListenerInterface /** lang 네임스페이스 접두사 (plugin.php 와 동일) */ private const LANG = 'sirsoft-message_bizppurio::messages'; - /** 이 플러그인이 등록하는 채널 ID 목록 */ - private const CHANNEL_IDS = ['sms', 'alimtalk']; + /** 이 플러그인이 등록하는 채널 ID 목록 (Plugin::cleanupChannelContributions 에서도 참조) */ + public const CHANNEL_IDS = ['sms', 'alimtalk']; /** 3영역 채널 후보 확장을 구독할 hookPrefix (D7) */ private const CHANNEL_HOOK_PREFIXES = ['core.auth', 'sirsoft-ecommerce', 'sirsoft-board']; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php index fbb57f22..8d6b251f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -28,6 +28,7 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; * @property string|null $to_name * @property int|null $to_user_id * @property string $content + * @property array|null $request_payload * @property string|null $notification_type * @property int|null $notification_log_id * @property string $status @@ -35,6 +36,7 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; * @property string|null $result_message * @property string|null $fallback_status * @property string $source + * @property bool|null $is_test_mode * @property Carbon|null $sent_at * @property Carbon|null $reported_at * @property array|null $raw_payload @@ -65,6 +67,7 @@ class BizppurioDispatch extends Model 'to_name', 'to_user_id', 'content', + 'request_payload', 'notification_type', 'notification_log_id', 'status', @@ -72,6 +75,7 @@ class BizppurioDispatch extends Model 'result_message', 'fallback_status', 'source', + 'is_test_mode', 'sent_at', 'reported_at', 'raw_payload', @@ -90,8 +94,10 @@ class BizppurioDispatch extends Model 'source' => DispatchSource::class, 'to_user_id' => 'integer', 'notification_log_id' => 'integer', + 'is_test_mode' => 'boolean', 'sent_at' => 'datetime', 'reported_at' => 'datetime', + 'request_payload' => 'array', 'raw_payload' => 'array', ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php index e52b5025..30ce00e2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -49,6 +50,9 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio */ class AlimtalkChannelDriver { + /** 플러그인 식별자 (manifest 와 일치) */ + private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (SmsChannelDriver 와 동일 계약) */ public const RECIPIENT_PHONE_KEY = '_recipient_phone'; @@ -60,6 +64,7 @@ class AlimtalkChannelDriver * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) * @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안) * @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안) + * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( private readonly NotificationTemplateService $templateService, @@ -69,6 +74,7 @@ class AlimtalkChannelDriver private readonly DispatchLinkContext $linkContext, private readonly KakaoTemplateContentResolver $kakaoContent, private readonly AlimtalkPayloadMapper $payloadMapper, + private readonly PluginSettingsService $pluginSettings, ) {} /** @@ -148,9 +154,11 @@ class AlimtalkChannelDriver 'to_name' => $notifiable->name ?? null, 'to_user_id' => $this->resolveUserId($notifiable), 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), 'notification_type' => $type, 'status' => DispatchStatus::Pending->value, 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), 'sent_at' => now(), ]); @@ -254,4 +262,16 @@ class AlimtalkChannelDriver { return Str::random(32); } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php index 8efb4f6e..754f3596 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php @@ -120,7 +120,7 @@ class AlimtalkPayloadMapper // 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환. foreach (self::LINK_FIELD_MAP as $from => $to) { if (isset($button[$from]) && $button[$from] !== '') { - $row[$to] = $this->substitute((string) $button[$from], $data); + $row[$to] = $this->substituteLinkField((string) $button[$from], $data); } } @@ -158,7 +158,7 @@ class AlimtalkPayloadMapper foreach (self::LINK_FIELD_MAP as $from => $to) { if (isset($link[$from]) && $link[$from] !== '') { - $mapped[$to] = $this->substitute((string) $link[$from], $data); + $mapped[$to] = $this->substituteLinkField((string) $link[$from], $data); } } @@ -258,4 +258,40 @@ class AlimtalkPayloadMapper return strtr($text, $replacements); } + + /** + * 버튼/링크 URL 필드를 치환하고, 프로토콜 중복을 방어합니다. + * + * 코어 알림 data 의 `*_url` 계열 변수(action_url 등)는 항상 `config('app.url')` 기반의 + * 프로토콜 포함 완전한 URL 이다(mail 채널이 href="{action_url}" 로 원문 그대로 소비하는 + * 계약). 반면 카카오 알림톡 버튼은 `http://#{action_url}` 처럼 원문에 프로토콜 접두어를 + * 직접 붙여 등록되는 경우가 있어, 단순 문자열 치환 시 `http://https://…` 형태로 프로토콜이 + * 중복될 수 있다. + * + * 원문이 `http://`/`https://` 로 시작 + 치환 결과가 (그 접두어를 제거했을 때) 다시 + * `http://`/`https://` 로 시작하는 경우에만 원문의 선행 접두어를 제거한다. 변수가 이미 + * 프로토콜 없이 등록된 경우(가이드 문서 원안)나, 변수값 자체가 프로토콜을 포함하지 않는 + * 경우(상대경로 등)는 원문을 그대로 둔다 — 후자를 건드리면 오히려 필요한 접두어가 사라진다. + * + * @param string $text 치환 대상(#{key} 포함, 링크 필드 원문) + * @param array $data 변수 치환 소스 + * @return string 치환되고 프로토콜 중복이 제거된 문자열 + */ + private function substituteLinkField(string $text, array $data): string + { + $substituted = $this->substitute($text, $data); + + foreach (['http://', 'https://'] as $prefix) { + if (! str_starts_with($text, $prefix)) { + continue; + } + + $afterPrefix = substr($substituted, strlen($prefix)); + if (str_starts_with($afterPrefix, 'http://') || str_starts_with($afterPrefix, 'https://')) { + return $afterPrefix; + } + } + + return $substituted; + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php index d21b49cd..37fe546c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -76,7 +76,7 @@ class DispatchResultService * dispatch 1건을 화면 표시용 결과 배열로 변환합니다. * * @param BizppurioDispatch $dispatch 연결된 발송 이력 - * @return array 상태·결과 라벨·잔액부족·대체발송 표시 데이터 + * @return array 상태·결과 라벨·잔액부족·대체발송·검수 모드 표시 데이터 */ private function buildResult(BizppurioDispatch $dispatch): array { @@ -92,6 +92,8 @@ class DispatchResultService 'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code), 'fallback_status' => $dispatch->fallback_status, 'channel' => $dispatch->channel?->value, + // 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시). + 'is_test_mode' => $dispatch->is_test_mode, ]; } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php index 2de0bace..672d0047 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php @@ -120,6 +120,34 @@ class MessagePayloadBuilder ]; } + /** + * 발송 payload 에서 발송 이력(request_payload) 저장용으로 개인식별 정보를 제거합니다. + * + * 이력 테이블에 이미 저장되는 필드(to→to_number, refkey→refkey, type→channel, + * content.{sms,lms,at}.message→content)는 완전 중복이므로 제외한다. 나머지(account/ + * from/senderkey/templatecode/button 등 요소·resend/recontent)는 이력 테이블 어디에도 + * 없고 발송 실패 원인 분석(결함①)·버튼 URL 검증(결함②)에 필요하므로 그대로 남긴다. + * + * @param array $payload buildSms/buildLms/buildAlimtalk 가 조립한 발송 payload + * @return array 개인식별 정보를 제거한 이력 저장용 payload + */ + public function forHistory(array $payload): array + { + unset($payload['to'], $payload['refkey'], $payload['type']); + + foreach (['sms', 'lms', 'at'] as $channelKey) { + if (isset($payload['content'][$channelKey]['message'])) { + unset($payload['content'][$channelKey]['message']); + } + } + + if (isset($payload['recontent']['sms']['message'])) { + unset($payload['recontent']['sms']['message']); + } + + return $payload; + } + /** * 발송 계정(account = bizppurio_id)을 반환합니다. */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index f9a953dc..a7b92c9d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -37,6 +38,9 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep */ class SmsChannelDriver { + /** 플러그인 식별자 (manifest 와 일치) */ + private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (extract_data 리스너와 계약) */ public const RECIPIENT_PHONE_KEY = '_recipient_phone'; @@ -46,6 +50,7 @@ class SmsChannelDriver * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) + * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( private readonly NotificationTemplateService $templateService, @@ -53,6 +58,7 @@ class SmsChannelDriver private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, private readonly DispatchLinkContext $linkContext, + private readonly PluginSettingsService $pluginSettings, ) {} /** @@ -114,9 +120,11 @@ class SmsChannelDriver 'to_name' => $notifiable->name ?? null, 'to_user_id' => $this->resolveUserId($notifiable), 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), 'notification_type' => $type, 'status' => DispatchStatus::Pending->value, 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), 'sent_at' => now(), ]); @@ -174,4 +182,16 @@ class SmsChannelDriver { return Str::random(32); } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php new file mode 100644 index 00000000..f19bfff6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php @@ -0,0 +1,144 @@ +plugin = new Plugin; + } + + /** + * activate() 로 채널이 증강된 상태에서 uninstall() 을 호출하면, 코어 알림 정의는 + * 보존한 채 sms·alimtalk template 과 channels 배열의 우리 채널만 제거된다. + */ + public function test_uninstall_시_sms_alimtalk_템플릿이_정리되고_코어_정의는_보존된다(): void + { + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + '사전조건: activate() 후 sms·alimtalk 템플릿이 존재해야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + + $this->plugin->uninstall(); + + $this->assertSame( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + 'uninstall() 후에는 sms·alimtalk 채널 템플릿이 하나도 남으면 안 된다.' + ); + + $welcome->refresh(); + $this->assertEqualsCanonicalizing( + ['mail', 'database'], + $welcome->channels, + 'welcome 정의는 삭제되지 않고, channels 배열에서 sms·alimtalk 만 제거되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'mail')->first(), + 'mail 채널 템플릿은 그대로 보존되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'database')->first(), + 'database 채널 템플릿은 그대로 보존되어야 한다.' + ); + } + + /** + * 게시판·이커머스처럼 코어가 아닌 다른 모듈이 소유한 알림 정의도 동일하게, + * 정의 자체는 남고 우리 채널만 제거된다. + */ + public function test_uninstall_시_모듈_소유_알림도_채널만_정리되고_정의는_보존된다(): void + { + $this->plugin->activate(); + + $boardDefinition = NotificationDefinition::where('extension_identifier', 'sirsoft-board')->first(); + + if (! $boardDefinition) { + $this->markTestSkipped('sirsoft-board 모듈이 활성화되지 않은 테스트 환경입니다.'); + } + + $this->assertContains('sms', $boardDefinition->channels); + + $this->plugin->uninstall(); + + $boardDefinition->refresh(); + $this->assertNotContains('sms', $boardDefinition->channels); + $this->assertNotContains('alimtalk', $boardDefinition->channels); + $this->assertSame( + 0, + NotificationTemplate::where('definition_id', $boardDefinition->id) + ->whereIn('channel', ['sms', 'alimtalk']) + ->count() + ); + } + + /** + * 제거 후 재설치(재활성화)하면 activate() 의 재시딩 경로로 채널이 다시 생성된다(회귀 방지). + */ + public function test_제거_후_재활성화하면_채널이_다시_생성된다(): void + { + $this->plugin->activate(); + $this->plugin->uninstall(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'sms')->count(), + '재활성화 후 sms 채널 템플릿이 다시 생성되어야 한다.' + ); + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'alimtalk')->count(), + '재활성화 후 alimtalk 채널 템플릿이 다시 생성되어야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + } + + /** + * 채널 증강이 전혀 없는 상태(activate 를 호출하지 않은 상태)에서 uninstall() 을 + * 호출해도 예외 없이 안전하게 통과해야 한다(정리 대상 0건). + */ + public function test_증강된_채널이_없는_상태에서_uninstall해도_안전하다(): void + { + app(NotificationDefinitionSeeder::class)->run(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $result = $this->plugin->uninstall(); + + $this->assertTrue($result); + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 2406c567..da7b1562 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; +use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; @@ -25,6 +26,21 @@ abstract class PluginTestCase extends TestCase */ private ?array $hookSnapshot = null; + /** + * HookListenerRegistrar::$registered 스냅샷 — tearDown 에서 복원. + * + * HookManager 의 hooks/filters 는 스냅샷 복원되지만, HookListenerRegistrar 는 + * "source::listenerClass" 키로 별도의 static 등록 여부 캐시를 갖는다(동일 PHP + * process 내 중복 등록 방지용, idempotent 보장). 이 캐시를 복원하지 않으면, + * 어떤 테스트에서 activate() 로 리스너를 한 번 등록한 뒤 HookManager 상태만 + * 복원되고 이 캐시는 "등록됨"으로 남아, 다음 테스트에서 같은 리스너를 다시 + * register() 해도 이미 등록된 것으로 판단해 스킵된다 — 실제로는 훅이 비어있는데 + * 등록 로직만 건너뛰어 필터가 전혀 발동하지 않는 조용한 실패로 이어진다. + * + * @var array|null + */ + private ?array $hookListenerRegistrarSnapshot = null; + /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */ private ?string $isolatedPluginsRoot = null; @@ -37,6 +53,7 @@ abstract class PluginTestCase extends TestCase $this->isolatePluginStorage(); $this->snapshotHookManager(); + $this->snapshotHookListenerRegistrar(); } /** @@ -45,6 +62,7 @@ abstract class PluginTestCase extends TestCase protected function tearDown(): void { $this->restoreHookManager(); + $this->restoreHookListenerRegistrar(); $this->restorePluginStorage(); parent::tearDown(); @@ -121,6 +139,31 @@ abstract class PluginTestCase extends TestCase $this->hookSnapshot = null; } + /** + * HookListenerRegistrar static $registered 를 스냅샷. + */ + private function snapshotHookListenerRegistrar(): void + { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $this->hookListenerRegistrarSnapshot = $prop->getValue(); + } + + /** + * 스냅샷 시점으로 HookListenerRegistrar::$registered 를 복원. + */ + private function restoreHookListenerRegistrar(): void + { + if ($this->hookListenerRegistrarSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $ref->getProperty('registered')->setValue(null, $this->hookListenerRegistrarSnapshot); + + $this->hookListenerRegistrarSnapshot = null; + } + /** * 마이그레이션 경로를 반환합니다. * 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 8fddd63d..c181392f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; @@ -102,12 +103,14 @@ class AlimtalkChannelDriverTest extends PluginTestCase * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. * * @param array|null $kakao 카카오 상세조회 내용 (null=조회 실패) + * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) */ private function makeDriver( ?NotificationTemplate $template, ?BizppurioNotificationBinding $binding, ?MessagePayloadBuilder $builder = null, array|false|null $kakao = false, + bool $isTestMode = true, ): AlimtalkChannelDriver { $templateService = Mockery::mock(NotificationTemplateService::class); $templateService->shouldReceive('resolve') @@ -117,6 +120,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase // $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패. $content = $kakao === false ? $this->kakaoContent() : $kakao; + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + return new AlimtalkChannelDriver( $templateService, $this->fakeBindings($binding), @@ -125,6 +133,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase new DispatchLinkContext, $this->fakeKakaoContent($content), new AlimtalkPayloadMapper, + $pluginSettings, ); } @@ -353,6 +362,44 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertSame('order_confirmed', $dispatch->notification_type); } + public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertSame('TW_1234', $dispatch->request_payload['templatecode'] ?? null); + } + + public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 + // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(''); + $realBuilder = new MessagePayloadBuilder($pluginSettings); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $realBuilder) + ->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $payload = $dispatch->request_payload; + + $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('message', $payload['content']['at'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.'); + } + public function test_전화번호가_없으면_발송하지_않는다(): void { Bus::fake(); @@ -386,4 +433,26 @@ class AlimtalkChannelDriverTest extends PluginTestCase Bus::assertDispatched(SendMessageJob::class); $this->assertDatabaseCount('bizppurio_dispatches', 1); } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: true) + ->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: false) + ->send($member, $this->notification()); + + $this->assertFalse(BizppurioDispatch::first()->is_test_mode); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php index 4e8e4c03..750f9c43 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php @@ -148,6 +148,112 @@ class AlimtalkPayloadMapperTest extends PluginTestCase $this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']); } + public function test_원문에_프로토콜_접두어가_있고_변수값도_완전url이면_중복을_제거한다(): void + { + // 결함② — 카카오 콘솔에 `http://#{action_url}` 로 등록된 버튼. action_url 자체가 + // config('app.url') 기반 완전 URL(https://...)이라 단순 치환 시 프로토콜이 중복된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어(http://)를 제거해 프로토콜 중복(http://https://...)을 방지해야 한다.', + ); + } + + public function test_원문_접두어가_https이고_변수값도_https이면_중복을_제거한다(): void + { + // 실제 카카오 콘솔 등록값(관리자 화면 상세조회 스크린샷 확인, 2026-07-24) — + // "회원가입 환영" 템플릿 버튼이 http:// 가 아니라 https://#{action_url} 로 등록돼 있다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'https://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어가 https:// 인 경우도 동일하게 중복(https://https://...)을 방지해야 한다.', + ); + } + + public function test_원문에_프로토콜_접두어가_없으면_변수값을_그대로_둔다(): void + { + // 가이드 문서 원안(#{action_url} 만 등록)대로면 애초에 중복이 없으므로 손대지 않는다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => '#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['button'][0]['url_mobile']); + } + + public function test_원문_접두어가_있어도_변수값이_상대경로면_접두어를_유지한다(): void + { + // 변수값 자체가 프로토콜을 포함하지 않는 경우(상대경로 등) 접두어는 진짜로 필요한 + // 부분이므로 제거하면 안 된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '이동', 'linkType' => 'WL', 'linkMo' => 'http://#{path}'], + ], + ], + ['path' => '/foo'], + ); + + $this->assertSame('http:///foo', $result['extra']['button'][0]['url_mobile']); + } + + public function test_대표링크도_프로토콜_중복을_제거한다(): void + { + // mapLinkFields(대표링크)도 mapButtons 와 동일 규칙을 공유해야 한다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateRepresentLink' => ['linkMo' => 'http://#{action_url}'], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['link']['url_mobile']); + } + + public function test_변수가_data에_없으면_원문을_유지한다(): void + { + // data 에 없는 변수는 원문(#{key}) 유지 정책 — 프로토콜 중복 방어 로직이 이 정책을 + // 깨서는 안 된다(원문이 http:// 로 시작하지만 치환 자체가 안 일어나므로 그대로 둔다). + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{unknown_var}'], + ], + ], + [], + ); + + $this->assertSame('http://#{unknown_var}', $result['extra']['button'][0]['url_mobile']); + } + public function test_부재_필드는_extra에_넣지_않는다(): void { // 본문만 있는 단순 템플릿 → extra 는 비어야 한다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php index d9f3d269..5f1bbb5c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -90,6 +90,24 @@ class DispatchResultServiceTest extends PluginTestCase $this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중) } + public function test_검수_모드_스냅샷이_결과에_포함된다(): void + { + $this->linkedDispatch(70, ['is_test_mode' => true]); + + $results = $this->service->resultsForLogIds([70]); + + $this->assertTrue($results[70]['is_test_mode']); + } + + public function test_검수_모드_스냅샷이_없는_과거_이력은_null이다(): void + { + $this->linkedDispatch(71, ['is_test_mode' => null]); + + $results = $this->service->resultsForLogIds([71]); + + $this->assertNull($results[71]['is_test_mode']); + } + public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void { $this->linkedDispatch(50); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php index 1963c693..af70caf9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php @@ -93,6 +93,77 @@ class MessagePayloadBuilderTest extends PluginTestCase $this->assertSame('확인', $at['button'][0]['name']); } + public function test_forHistory는_sms_payload에서_to_refkey_type_message를_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('to', $history); + $this->assertArrayNotHasKey('refkey', $history); + $this->assertArrayNotHasKey('type', $history); + $this->assertArrayNotHasKey('message', $history['content']['sms']); + // 다른 컬럼에 없는 값은 유지돼야 한다. + $this->assertSame('acct01', $history['account']); + $this->assertSame('07012345678', $history['from']); + } + + public function test_forHistory는_lms_payload에서_subject는_유지하고_message만_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['content']['lms']); + $this->assertSame('제목', $history['content']['lms']['subject'], 'subject 는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + + public function test_forHistory는_알림톡_payload에서_message만_제외하고_버튼_등_요소는_유지한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + $history = $builder->forHistory($payload); + + $at = $history['content']['at']; + $this->assertArrayNotHasKey('message', $at, '치환된 본문은 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertSame('senderkey40', $at['senderkey'], 'senderkey 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('TW_1234', $at['templatecode'], 'templatecode 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('확인', $at['button'][0]['name'], '버튼(요소)은 결함② URL 검증에 필요하므로 유지돼야 한다.'); + } + + public function test_forHistory는_대체발송_recontent_message도_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999'); + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => '대체 SMS 본문']]; + + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['recontent']['sms'], '대체발송 본문도 개인식별 텍스트이므로 제외돼야 한다.'); + $this->assertSame(['first' => 'sms'], $history['resend'], 'resend 구조 자체는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + protected function tearDown(): void { Mockery::close(); 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 407ba99e..c68a3393 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; @@ -47,20 +48,30 @@ class SmsChannelDriverTest extends PluginTestCase * 템플릿 resolve 결과를 지정한 driver 를 만듭니다. * * @param NotificationTemplate|null $template resolve 반환값 + * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) */ - private function makeDriver(?NotificationTemplate $template, ?MessagePayloadBuilder $builder = null): SmsChannelDriver - { + private function makeDriver( + ?NotificationTemplate $template, + ?MessagePayloadBuilder $builder = null, + bool $isTestMode = true, + ): SmsChannelDriver { $templateService = Mockery::mock(NotificationTemplateService::class); $templateService->shouldReceive('resolve') ->with(Mockery::any(), 'sms') ->andReturn($template); + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + return new SmsChannelDriver( $templateService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, new DispatchLinkContext, + $pluginSettings, ); } @@ -160,6 +171,41 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.'); } + public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertArrayNotHasKey('to', $dispatch->request_payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + } + + public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 + // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(''); + $realBuilder = new MessagePayloadBuilder($pluginSettings); + + $this->makeDriver($this->fakeTemplate(), $realBuilder)->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $payload = $dispatch->request_payload; + + $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('message', $payload['content']['sms'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); + } + public function test_발송하지_않으면_이력도_생성하지_않는다(): void { Bus::fake(); @@ -236,4 +282,24 @@ class SmsChannelDriverTest extends PluginTestCase Bus::assertDispatched(SendMessageJob::class); $this->assertDatabaseCount('bizppurio_dispatches', 1); } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), isTestMode: true)->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), isTestMode: false)->send($member, $this->notification()); + + $this->assertFalse(BizppurioDispatch::first()->is_test_mode); + } }