feat(message_bizppurio): 알림톡 템플릿 라이프사이클 개편 — DB 기반 작성·검수·승인

비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내
작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다.
발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지.

- 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체
- 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request
- 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결
- SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송
- 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면
- 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단
- admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원
- 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정,
 PHPUnit 421 / Vitest 228 green, audit 0 error
This commit is contained in:
HeuJung
2026-08-22 22:44:57 +09:00
parent 6a8a537f10
commit e17731d8cc
111 changed files with 24733 additions and 6890 deletions
+1 -1
View File
@@ -168,7 +168,7 @@
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 7 / 13 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
@@ -8,4 +8,4 @@
### Added
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다.
@@ -6,6 +6,10 @@ return [
'lms' => 'LMS',
'alimtalk' => 'アラートトーク',
],
'channel_group' => [
'text' => '文字',
'alimtalk' => '通知トーク',
],
'status' => [
'pending' => '待機中',
'sent' => '送信中',
@@ -26,11 +30,11 @@ return [
'channels' => [
'source_label' => 'ビズプリオ',
'sms' => [
'name' => 'SMS/LMSテキスト',
'name' => 'ビズプリオ 文字',
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
],
'alimtalk' => [
'name' => 'カカオアラートトーク',
'name' => 'ビズプリオ アラートトーク',
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
],
],
@@ -51,33 +55,102 @@ return [
'error' => [
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
'image_unreadable' => 'アップロードした画像ファイルを読み込めません。',
],
'send_skipped' => [
'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)',
'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)',
'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)',
'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)',
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
],
'binding' => [
'saved' => 'アラートトーク連携を保存しました。',
'removed' => 'アラートトーク連携を解除しました。',
'template' => [
'created' => '通知テンプレートを保存しました。',
'updated' => '通知テンプレートを更新しました。',
'requested' => '審査を申請しました。承認結果は自動的に同期され、[更新]ですぐに確認できます。',
'request_cancelled' => '審査申請を取り消しました。',
'approval_cancelled' => '承認を取り消しました。この通知のアラートトーク送信が停止されました。',
'released' => '休眠状態を解除しました。',
'synced' => 'カカオ審査ステータスを同期しました。',
'deleted' => '通知テンプレートを削除しました。',
'image_uploaded' => '画像をアップロードしました。',
'content_locked' => '現在のステータス(:status)ではアラートトークの内容を修正できません。審査中の場合は申請の取り消しを、承認済みの場合は承認の取り消しを先に行ってください。',
'content_missing' => 'アラートトークテンプレートの内容を先に作成してください。',
'request_not_allowed' => '現在のステータス(:status)では審査を申請できません。',
'cancel_request_not_allowed' => '審査中ステータスではないため、申請を取り消せません。(現在: :status)',
'cancel_approval_not_allowed' => '承認ステータスではないため、承認を取り消せません。(現在: :status)',
'release_not_allowed' => '休眠ステータスではないため、解除できません。(現在: :status)',
'code_generation_failed' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。',
],
'cache' => [
'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
],
'channel_group' => [
'text' => '文字',
'alimtalk' => '通知トーク',
'validation' => [
'link_mo_required' => 'ウェブリンク(WL)ボタンにはモバイルリンクが必要です。',
'link_and_required' => 'アプリリンク(AL)ボタンにはAndroidスキームが必要です。',
'link_ios_required' => 'アプリリンク(AL)ボタンにはiOSスキームが必要です。',
'tel_number_required' => '電話(TN)ボタンには電話番号が必要です。',
'plugin_id_required' => 'プラグイン(P1~P3)ボタンにはプラグインIDが必要です。',
'highlight_title_too_long' => 'アイテムハイライトのタイトルは:max文字以内である必要があります。',
'highlight_description_too_long' => 'アイテムハイライトの説明は:max文字以内である必要があります。',
'image_ratio_invalid' => '画像は横:縦 2:1 の比率である必要があります。(例: 1000×500px)',
// 検証エラー文に使うフィールドラベル (FormRequest::attributes)
'attributes' => [
'notification_type' => '通知タイプ',
'content' => 'アラートトーク内容',
'template_name' => 'テンプレート名',
'message_type' => 'メッセージタイプ',
'emphasize_type' => '強調タイプ',
'template_content' => '本文',
'preview_message' => 'プレビュー文言',
'category_code' => 'カテゴリ',
'security_flag' => 'セキュリティテンプレート',
'extra' => '付加情報',
'title' => '強調タイトル',
'subtitle' => '強調サブタイトル',
'header' => 'ヘッダー',
'image_name' => '画像ファイル名',
'image_url' => '画像URL',
'item' => 'アイテムリスト',
'item_list' => 'アイテム一覧',
'item_title' => 'アイテム名',
'item_description' => 'アイテム説明',
'summary' => '要約',
'summary_title' => '要約タイトル',
'summary_description' => '要約説明',
'highlight' => 'ハイライト',
'highlight_title' => 'ハイライトタイトル',
'highlight_description' => 'ハイライト説明',
'highlight_image_url' => 'ハイライト画像URL',
'represent_link' => '代表リンク',
'buttons' => 'ボタン',
'button_name' => 'ボタン名',
'button_link_type' => 'ボタンリンクタイプ',
'quick_replies' => 'クイックリプライ',
'quick_reply_name' => 'クイックリプライ名',
'quick_reply_link_type' => 'クイックリプライリンクタイプ',
'link_mo' => 'モバイルリンク',
'link_pc' => 'PCリンク',
'link_and' => 'Androidスキーム',
'link_ios' => 'iOSスキーム',
'tel_number' => '電話番号',
'plugin_id' => 'プラグインID',
'image' => '画像ファイル',
'sms_body' => 'SMS本文',
'alimtalk_enabled' => 'アラートトーク利用',
'fallback_sms_enabled' => '代替SMS利用',
'sms_only' => 'SMS単独送信',
'is_active' => '有効',
'status' => 'ステータス',
'search' => '検索キーワード',
'page' => 'ページ',
'per_page' => '1ページあたりの件数',
],
],
'token_check' => [
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
@@ -34,6 +34,15 @@
"label": "ビズプリオモジュールパスワード",
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
},
"connection_check": {
"label": "接続確認",
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
"button": "接続確認",
"checking": "確認中...",
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
"failed": "認証確認に失敗しました。",
"unsaved_changes": "変更内容を先に保存してください。"
},
"api_key": {
"label": "API キー",
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
@@ -45,23 +54,6 @@
"sender_key": {
"label": "アラート톡 発信プロファイル キー",
"hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。"
},
"template_cache_minutes": {
"label": "アラート톡 内容 キャッシュ 時間(分)",
"hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。",
"clear_cache": "キャッシュ 初期化",
"clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
"clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
"clear_cache_failed": "キャッシュ 初期化に失敗しました。"
},
"connection_check": {
"label": "接続確認",
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
"button": "接続確認",
"checking": "確認中...",
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
"failed": "認証確認に失敗しました。",
"unsaved_changes": "変更内容を先に保存してください。"
}
},
"report": {
@@ -71,13 +63,9 @@
"copy": "コピー",
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
},
"cache": {
"section_title": "アラート톡 発送内容 キャッシュ",
"section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。"
},
"tabs": {
"connection": "環境設定",
"templates": "アラート톡 テンプレート"
"templates": "通知テンプレート管理"
},
"preparation": {
"intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
@@ -85,31 +73,16 @@
"sms_sender": "発信番号を登録してください。",
"kakao_label": "カカオ アラート톡",
"kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
"kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
"kakao_template": "アラームトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。",
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
"console_link": "ビズプリオ コンソールを開く"
}
},
"channels": {
"bizppurio_tab": "ビズプリオ"
},
"binding": {
"section_title": "カカオ アラート톡 連動",
"list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。",
"section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。",
"modal_title": "アラート톡 接続 · {name}",
"unbound": "未接続",
"unavailable": "使用不可 — 再接続が必要",
"btn_connect": "接続",
"btn_change": "接続を変更",
"fallback_on": "SMS 代替 ON",
"fallback_off": "SMS 代替 OFF",
"connected_template": "接続テンプレート",
"none": "接続しない",
"no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。",
"templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。",
"fallback_sms": "失敗時 SMS で代替発送",
"fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。",
"variables_hint": "提供変数(発送時に自動置換)",
"saved": "アラート톡 連動を保存しました。",
"save_error": "アラート톡 連動 保存に失敗しました。"
"variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)"
},
"banner": {
"not_ready": "発送に必要な 設定が完了していません。",
@@ -124,70 +97,174 @@
"detail_title": "文字・アラート톡 発送 結果",
"channel_label": "発送チャネル: {channel}",
"sent_content_label": "実際の発送内容",
"sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。"
"sent_content_hint": "アラート톡 は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。"
},
"editor": {
"data_source": {
"dispatch_results": "ビズプリオ送信結果"
}
},
"template": {
"tab_guide": "通知ごとにアラームトークテンプレートを作成して審査を申請してください。カカオ承認後にアラームトークが自動送信され、代替SMS・SMS単独送信もこの画面で設定します。",
"status": {
"unwritten": "未作成",
"draft": "作成中",
"requested": "審査中",
"approved": "承認済み",
"rejected": "却下",
"stopped": "停止",
"blocked": "ブロック",
"dormant": "休眠"
},
"row": {
"alimtalk_label": "アラームトーク",
"requested_on": "{date} 申請",
"synced_on": "{date} 同期",
"alimtalk_enabled": "送信を使用",
"btn_view_reason": "理由を表示",
"btn_compose": "アラームトークテンプレート作成",
"btn_edit": "編集",
"btn_edit_approved": "編集 (承認取り消し)",
"btn_request": "審査申請",
"btn_cancel_request": "申請取り消し",
"btn_release": "休眠解除",
"btn_refresh": "更新",
"requested_toast": "審査を申請しました。",
"cancel_request_toast": "審査申請を取り消しました。",
"released_toast": "休眠状態を解除しました。",
"synced_toast": "カカオ審査ステータスを同期しました。",
"fallback_sms": "代替SMS",
"sms_only": "SMS単独",
"sms_body_prefix": "本文:",
"sms_body_missing": "SMS本文未設定",
"btn_edit_sms": "SMS本文を編集"
},
"form": {
"modal_title": "アラームトークテンプレート — {name}",
"rejected_banner": "却下されたテンプレートです。以下の理由を確認し、修正後に再度申請してください。",
"sender_profile": "発信プロフィール",
"sender_profile_placeholder": "発信プロフィール選択(参考用)",
"sender_profile_hint": "審査申請は環境設定に保存された発信プロフィールキーで行われます。",
"category": "カテゴリ",
"category_placeholder": "カテゴリ選択",
"name": "テンプレート名",
"message_type": "メッセージ種別",
"templateMessageType_options": {
"BA": "基本型",
"EX": "付加情報型",
"AD": "チャネル追加型",
"MI": "複合型"
},
"emphasize_type": "強調種別",
"templateEmphasizeType_options": {
"NONE": "なし",
"TEXT": "強調表記",
"IMAGE": "画像",
"ITEM_LIST": "アイテムリスト"
},
"emphasize_title": "強調表記タイトル",
"emphasize_subtitle": "強調表記サブタイトル",
"image": "画像",
"image_hint": "jpg/png · 500KB以下 · 横500px以上 · 横:縦 2:1 (アップロードするとURLが自動入力されます)",
"image_uploading": "画像をアップロード中...",
"image_upload_failed": "画像のアップロードに失敗しました。",
"content": "本文",
"extra": "付加情報",
"header": "ヘッダー",
"header_hint": "本文上部に表示される最大16文字の文言(任意)",
"buttons": "ボタン (最大5個)",
"button_add": "ボタン追加",
"button_name": "ボタン名 (最大14文字)",
"quick_replies": "クイックリンク (最大10個)",
"quick_reply_add": "クイックリンク追加",
"link_mo": "モバイルリンク (必須)",
"link_pc": "PCリンク (任意)",
"link_and": "Androidスキーム (必須)",
"link_ios": "iOSスキーム (必須)",
"tel_number": "電話番号 (必須)",
"plugin_id": "プラグインID (必須)",
"item_highlight": "アイテムハイライト (任意)",
"hl_title": "ハイライトタイトル",
"hl_description": "ハイライト説明",
"hl_image_url": "サムネイル画像URL (任意)",
"item_list": "アイテム一覧 (2~10個)",
"item_list_hint": "アイテムは最小2個、最大10個です。(タイトル6文字・説明23文字以内)",
"item_add": "アイテム追加",
"item_title": "タイトル",
"item_description": "説明",
"item_summary": "要約(価格情報)",
"summary_description": "要約値 (14文字以内)",
"btn_remove_item": "アイテム削除",
"btn_remove_button": "ボタン削除",
"btn_remove_quick_reply": "クイックリプライ削除",
"btn_save": "保存",
"btn_save_request": "保存して審査申請",
"saved_toast": "通知テンプレートを保存しました。",
"requested_toast": "審査を申請しました。承認されるとアラームトーク送信が開始されます。"
},
"sms": {
"modal_title": "SMS本文 — {name}",
"hint": "代替SMSとSMS単独送信に共通で使用する本文です。#{변수} は送信時に自動置換されます。",
"btn_save": "保存",
"saved_toast": "SMS本文を保存しました。"
},
"cancel_approval": {
"modal_title": "承認取り消しの確認",
"warning": "「{name}」テンプレートの承認を取り消すと、直ちにこの通知のアラームトーク送信が停止されます。(代替SMS・SMS単独送信はSMS設定に従って継続されます。)修正後に再度審査を申請して承認を受けると、アラームトーク送信が再開されます。",
"btn_confirm": "承認を取り消して編集",
"done_toast": "承認を取り消しました。修正後に再度審査を申請してください。"
},
"rejection": {
"modal_title": "却下理由 — {name}",
"btn_close": "閉じる"
}
},
"manage": {
"guide": "すべての通知のビズプリオテンプレート状態を一目で管理します。作成・審査申請は通知設定画面と同じように動作します。",
"filter_all": "すべてのステータス",
"search_placeholder": "通知名/タイプ検索",
"btn_search": "検索",
"total_count": "合計 {count}件",
"btn_compose": "作成",
"btn_edit": "編集",
"btn_delete": "削除",
"empty": "登録されたビズプリオ通知テンプレートがありません。",
"empty_hint": "管理者の通知設定のビズプリオタブでアラームトークテンプレートを作成するか、SMSを設定するとここに表示されます。",
"sms_only": "単独",
"sms_fallback": "代替",
"owner": {
"core": "コア",
"module": "モジュール",
"plugin": "プラグイン"
},
"columns": {
"notification": "通知",
"owner": "所属",
"status": "アラームトークステータス",
"sms": "SMS",
"requested_at": "申請日",
"synced_at": "同期",
"actions": "管理"
},
"delete": {
"modal_title": "テンプレート削除",
"warning": "「{name}」のビズプリオテンプレート設定を削除します。カカオに登録されたテンプレートは削除可能なステータス(登録/却下)の場合のみ一緒に削除されます。",
"btn_confirm": "削除",
"done_with_kakao": "テンプレートを削除しました。(カカオ側テンプレートを含む)",
"done_db_only": "テンプレート設定を削除しました。カカオ側テンプレートはステータス制約により残っています。"
}
},
"templates": {
"title": "アラームトークテンプレート",
"description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。",
"readiness": {
"title": "アラームトークテンプレートを使用するには、以下の設定が必要です",
"go_settings": "環境設定に移動",
"missing_label": "未設定項目",
"api_key_missing": "カカオ管理API キー",
"sender_key_missing": "アラームトーク送信プロフィールキー",
"note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。"
},
"list_error": {
"title": "テンプレート一覧を読み込めませんでした"
},
"list_notice": {
"console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。",
"console_link": "ビズプリオコンソールを開く"
"note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。"
},
"list": {
"refresh": "更新",
"search": "検索",
"search_placeholder": "テンプレート名検索(2~50文字)",
"filter_all": "すべてのステータス",
"empty": "登録されたアラームトークテンプレートがありません。",
"empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。",
"load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。",
"columns": {
"no": "番号",
"name": "テンプレート名",
"code": "コード",
"status": "ステータス",
"requested_at": "登録申請日",
"processed_at": "処理日",
"actions": "内容"
}
},
"status": {
"sendable": "送信可能",
"inspecting": "審査中",
"rejected": "却下",
"uninspected": "未審査",
"stopped": "停止",
"blocked": "ブロック",
"dormant": "休止",
"unknown": "不明"
},
"status_sub": {
"rdy": "(使用前)"
},
"status_guide": {
"title": "ステータスバッジ",
"sendable_label": "送信可能",
"sendable": "承認完了。このステータスのみアラームに接続·送信できます。",
"inspecting_label": "審査中",
"inspecting": "カカオ審査進行中(営業日2~3日)。",
"pending_label": "未審査·却下",
"pending": "まだ送信できません。コンソールで審査申請·修正してください。"
"refresh": "更新"
},
"link_type": {
"WL": "ウェブリンク",
@@ -203,25 +280,6 @@
"P1": "画像セキュア送信",
"P2": "個人情報利用",
"P3": "ワンクリック決済"
},
"actions": {
"detail": "詳細"
},
"detail": {
"title": "テンプレート詳細",
"close": "閉じる",
"buttons": "ボタン",
"extra": "付加情報",
"category": "カテゴリ",
"code": "テンプレートコード",
"content": "テンプレート内容",
"emphasize_type": "テンプレート種別",
"image_upload": "画像添付",
"subtitle_field": "補助文句",
"title_field": "強調表記文句",
"type_image": "画像形",
"type_none": "基本形",
"type_text": "強調表記形"
}
}
}
@@ -10,6 +10,11 @@
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
- 게시판 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
### Changed
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
## [1.0.5] - 2026-08-22
@@ -0,0 +1,158 @@
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
/**
* 게시판 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
*
* 이 면의 서브탭 필터는 admin_basic·이커머스와 같은 식을 공유한다. 그 동일성은
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
* 러너에서만** 수집된다 — 게시판 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
*
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
*
* @scenario resource=notification_definitions_tab,endpoint=admin_board_settings,observation=tab_filter
*
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const TAB_LAYOUT = path.resolve(
__dirname,
'../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json',
);
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
const CHANNELS = [
{ id: 'mail', source: 'core', name: '메일' },
{ id: 'database', source: 'core', name: '사이트내 알림' },
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
{
id: 'alimtalk',
source: 'sirsoft-message_bizppurio',
name: '비즈뿌리오 알림톡',
tab_channels: ['sms', 'alimtalk'],
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
},
];
/**
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
*
* @param node 탐색할 JSON 노드
* @returns 서브탭 컨테이너 노드 (없으면 null)
*/
function findSubTabsContainer(node: unknown): Record<string, any> | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = findSubTabsContainer(child);
if (found) return found;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const record = node as Record<string, any>;
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
for (const value of Object.values(record)) {
const found = findSubTabsContainer(value);
if (found) return found;
}
return null;
}
/**
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
*
* @param node 탐색할 JSON 노드
* @returns iteration 보유 노드 (없으면 null)
*/
function findIterationNode(node: unknown): Record<string, any> | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = findIterationNode(child);
if (found) return found;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const record = node as Record<string, any>;
if (record.iteration && typeof record.iteration.source === 'string') return record;
for (const value of Object.values(record)) {
const found = findIterationNode(value);
if (found) return found;
}
return null;
}
describe('게시판 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
const container = findSubTabsContainer(layout);
const tabNode = container ? findIterationNode(container) : null;
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
/**
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
*
* @param saved _local.form.notifications.channels 저장값
* @param channels availableChannels 채널 목록
* @returns 필터를 통과한 채널 id 배열
*/
function visibleTabs(
saved: Array<{ id: string; is_active: boolean }>,
channels: Array<Record<string, any>> = CHANNELS,
): string[] {
// eslint-disable-next-line no-new-func
const fn = new Function('availableChannels', '_local', `return ${expr};`);
const result = fn(
{ data: { channels } },
{ form: { notifications: { channels: saved } } },
) as Array<{ id: string }>;
return result.map((c) => c.id);
}
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
});
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
expect(visibleTabs([
{ id: 'sms', is_active: true },
{ id: 'alimtalk', is_active: true },
])).toEqual(['mail', 'database', 'alimtalk']);
});
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
expect(visibleTabs([
{ id: 'sms', is_active: true },
{ id: 'alimtalk', is_active: false },
])).toEqual(['mail', 'database', 'alimtalk']);
});
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
expect(visibleTabs([
{ id: 'sms', is_active: false },
{ id: 'alimtalk', is_active: false },
])).toEqual(['mail', 'database']);
});
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
expect(visibleTabs([])).toEqual(['mail', 'database']);
});
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
});
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
});
});
@@ -140,7 +140,7 @@
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
"item_var": "ch",
"index_var": "chIdx"
},
@@ -160,7 +160,7 @@
{
"type": "basic",
"name": "Span",
"text": "{{ch.name ?? ch.id}}"
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
}
],
"actions": [
@@ -9,6 +9,11 @@
### Added
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
- 이커머스 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
### Changed
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
### Fixed
@@ -0,0 +1,158 @@
// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
/**
* 이커머스 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
*
* 이 면의 서브탭 필터는 admin_basic·게시판와 같은 식을 공유한다. 그 동일성은
* admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
* 러너에서만** 수집된다 — 이커머스 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
* 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
*
* 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
* 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
*
* @scenario resource=notification_definitions_tab,endpoint=admin_ecommerce_settings,observation=tab_filter
*
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const TAB_LAYOUT = path.resolve(
__dirname,
'../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json',
);
/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
const CHANNELS = [
{ id: 'mail', source: 'core', name: '메일' },
{ id: 'database', source: 'core', name: '사이트내 알림' },
{ id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
{
id: 'alimtalk',
source: 'sirsoft-message_bizppurio',
name: '비즈뿌리오 알림톡',
tab_channels: ['sms', 'alimtalk'],
tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
},
];
/**
* id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
*
* @param node 탐색할 JSON 노드
* @returns 서브탭 컨테이너 노드 (없으면 null)
*/
function findSubTabsContainer(node: unknown): Record<string, any> | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = findSubTabsContainer(child);
if (found) return found;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const record = node as Record<string, any>;
if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
for (const value of Object.values(record)) {
const found = findSubTabsContainer(value);
if (found) return found;
}
return null;
}
/**
* 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
*
* @param node 탐색할 JSON 노드
* @returns iteration 보유 노드 (없으면 null)
*/
function findIterationNode(node: unknown): Record<string, any> | null {
if (Array.isArray(node)) {
for (const child of node) {
const found = findIterationNode(child);
if (found) return found;
}
return null;
}
if (!node || typeof node !== 'object') return null;
const record = node as Record<string, any>;
if (record.iteration && typeof record.iteration.source === 'string') return record;
for (const value of Object.values(record)) {
const found = findIterationNode(value);
if (found) return found;
}
return null;
}
describe('이커머스 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
const container = findSubTabsContainer(layout);
const tabNode = container ? findIterationNode(container) : null;
const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
/**
* 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
*
* @param saved _local.form.notifications.channels 저장값
* @param channels availableChannels 채널 목록
* @returns 필터를 통과한 채널 id 배열
*/
function visibleTabs(
saved: Array<{ id: string; is_active: boolean }>,
channels: Array<Record<string, any>> = CHANNELS,
): string[] {
// eslint-disable-next-line no-new-func
const fn = new Function('availableChannels', '_local', `return ${expr};`);
const result = fn(
{ data: { channels } },
{ form: { notifications: { channels: saved } } },
) as Array<{ id: string }>;
return result.map((c) => c.id);
}
it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
});
it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
expect(visibleTabs([
{ id: 'sms', is_active: true },
{ id: 'alimtalk', is_active: true },
])).toEqual(['mail', 'database', 'alimtalk']);
});
it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
expect(visibleTabs([
{ id: 'sms', is_active: true },
{ id: 'alimtalk', is_active: false },
])).toEqual(['mail', 'database', 'alimtalk']);
});
it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
expect(visibleTabs([
{ id: 'sms', is_active: false },
{ id: 'alimtalk', is_active: false },
])).toEqual(['mail', 'database']);
});
it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
expect(visibleTabs([])).toEqual(['mail', 'database']);
});
it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
});
it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
});
});
@@ -139,7 +139,7 @@
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
"item_var": "ch",
"index_var": "chIdx"
},
@@ -159,7 +159,7 @@
{
"type": "basic",
"name": "Span",
"text": "{{ch.name ?? ch.id}}"
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
}
],
"actions": [
@@ -9,6 +9,8 @@
### Added
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다.
- 알림 설정 화면의 '비즈뿌리오' 통합 탭에서 알림별 카카오 알림톡 템플릿을 직접 작성해 검수를 신청하고, 승인되면 그 내용으로 자동 발송됩니다. 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형과 버튼·바로연결을 지원하며, 검수 결과는 30분 주기로 자동 확인되고 [새로고침]으로 즉시 확인할 수 있습니다. 반려되면 사유를 화면에서 확인하고 수정 후 다시 신청합니다. 게시판·이커머스 알림 설정 화면에서도 동일하게 동작합니다.
- 알림별 대체 SMS(알림톡 실패 시)와 SMS 단독 발송 본문을 같은 화면에서 설정할 수 있습니다. 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다(해당 언어가 비어 있으면 기본 언어로 발송). [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다. 승인 취소 시에는 알림톡 발송이 즉시 중단됨을 경고합니다.
- 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
@@ -2,7 +2,7 @@
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.0-blue" alt="Version">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.3-0066FF" alt="G7">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.6-0066FF" alt="G7">
<img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>
@@ -11,7 +11,7 @@
G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
---
@@ -19,14 +19,14 @@ G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가
- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택
- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영
- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션)
- 알림톡 미승인·발송 불가 시 문자로 자동 대체발송(옵션)
- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용)
- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록
- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증
- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용)
- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시
- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행)
- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시
- 알림별 카카오 알림톡 템플릿을 관리자 화면에서 직접 작성 → 검수 신청 → 승인 후 자동 발송
- 검수 결과(승인·반려)를 30분 주기로 자동 확인하고, 반려 사유를 화면에서 확인해 수정 후 재신청
---
@@ -34,11 +34,11 @@ G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가
| 구분 | 항목 | 내용 |
|------|------|------|
| 플랫폼 | G7 | `>= 7.0.3` |
| 플랫폼 | G7 | `>= 7.0.6` |
| 플랫폼 | PHP | `^8.2` |
| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 |
| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) |
| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 |
| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 (템플릿은 이 플러그인 화면에서 작성·검수 신청) |
> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
@@ -59,7 +59,7 @@ npm install
npm run build
```
그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다.
그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널이 알림 설정에 등록됩니다.
---
@@ -75,7 +75,6 @@ npm run build
| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 |
| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 |
| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 |
| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 |
> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
@@ -99,8 +98,8 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
```text
코어/모듈이 알림 발화 (예: 회원가입, 주문 완료)
→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉
→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션)
→ 알림 설정에서 켜 둔 문자·알림톡 채널로 발송 작업 큐잉
→ 알림톡: 승인된 템플릿의 저장 내용으로 발송, 발송 불가 시 문자로 대체발송(옵션)
→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택
→ 비즈뿌리오 발송 API 호출
→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보
@@ -111,19 +110,44 @@ https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
---
## 알림톡 템플릿 연동
## 알림톡 템플릿 관리
카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다.
카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다.
작성 위치는 두 곳이고 화면 구성은 같습니다.
- **알림 설정 > 비즈뿌리오 탭** — 알림 항목마다 알림톡 템플릿과 대체 SMS 를 함께 설정합니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다.
- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다.
기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다.
메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 변수를 넣을 수 있습니다.
이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 단계에서 사유와 함께 거부됩니다.
| 상태 | 발송 가능 | 설명 |
|:---:|:---:|------|
| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 |
| 검수중 | ❌ | 카카오 검수 진행 중 |
| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 |
| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 |
| 중지 | ❌ | 사용 중지된 템플릿 |
| 미작성 | ❌ | 템플릿 내용을 아직 작성하지 않은 상태 |
| 작성중 | ❌ | 저장했으나 검수를 신청하기 전 상태 (내용 수정 가능) |
| 검수중 | ❌ | 카카오 검수 진행 중. 내용을 고치려면 먼저 [신청 취소] |
| 승인 | ✅ | 검수 승인 완료 — 이 알림이 발생하면 알림톡이 발송됩니다 |
| 반려 | ❌ | 카카오 검수 반려. 화면에서 사유를 확인하고 수정 후 재신청 |
| 중지 | ❌ | 카카오에서 사용 중지된 템플릿 |
| 차단 | ❌ | 카카오에서 차단된 템플릿 |
| 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 |
알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다.
검수 결과는 30분 주기로 자동 확인하며, 각 행의 [새로고침]으로 즉시 확인할 수도 있습니다.
승인된 템플릿의 내용을 고치려면 [수정]을 눌러 승인을 먼저 취소해야 합니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 화면에서 확인 후 진행합니다.
### 문자(SMS) 본문
알림마다 문자 본문을 따로 입력합니다. 쓰임은 두 가지이고 본문은 하나를 공유합니다.
- **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다.
- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다.
문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다.
---
@@ -189,9 +213,9 @@ HookManager::addAction(
| 문서 | 내용 |
|------|------|
| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 |
| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 |
| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API |
| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 |
| [templates.md](docs/api/templates.md) | 알림톡 템플릿 라이프사이클 관리 (작성·검수 신청·상태 동기화·발송 설정) |
| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 카테고리·발신프로필 조회 |
| [token.md](docs/api/token.md) | 비즈뿌리오 인증 토큰 |
| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 |
| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 |
@@ -200,7 +224,7 @@ HookManager::addAction(
| 권한 | 설명 |
|------|------|
| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 |
| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 |
| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 등 관리 작업 |
---
@@ -1,51 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 테이블.
*
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장한다.
* 변수 매핑 컬럼은 없다 — 변수명 동일 규칙으로 발송 시 자동 치환한다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
Schema::create('bizppurio_notification_bindings', function (Blueprint $table) {
$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', 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('연동 활성 여부');
$table->timestamps();
$table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_notification_bindings', function (Blueprint $table) {
$table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
}
};
@@ -0,0 +1,68 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 알림 템플릿 테이블 (#597).
*
* 알림 1건(notification_definitions.type)당 1행. 알림톡 템플릿의 카카오 등록
* 페이로드(content)·승인 스냅샷(approved_content)·검수 상태와, 대체 SMS/SMS 단독
* 본문(sms_body)을 함께 저장한다. 발송 시 DB 가 유일한 판정 근거다(실시간 조회 폐지).
*
* sms_body 는 다국어 맵이다 — 알림톡 content 는 카카오가 승인한 원문 그대로만 발송할 수
* 있어 단일 언어 1벌이지만, SMS 에는 그 제약이 없으므로 코어 알림 템플릿과 동일하게
* 수신자 로케일별 본문을 유지한다(개편 전 동작 보존).
*
* bizppurio_notification_bindings 를 대체한다 — 공개 릴리즈 이력이 없어 마이그레이션
* 자체를 교체했다(업그레이드 스텝 불요, 개발 환경은 플러그인 재설치 필요).
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_templates');
Schema::create('bizppurio_templates', function (Blueprint $table) {
$table->bigIncrements('id')->comment('템플릿 PK');
$table->string('notification_type', 100)->comment('코어 notification_definitions.type (알림 1건당 1행)');
$table->boolean('alimtalk_enabled')->default(false)->comment('알림톡 발송 사용 여부');
$table->string('template_code', 30)->nullable()->comment('자체 채번한 카카오 템플릿 코드 (등록 전 null)');
$table->string('sender_key', 100)->nullable()->comment('신청 당시 발신프로필 키 스냅샷');
$table->json('content')->nullable()->comment('카카오 등록 페이로드 (templateName/templateMessageType/templateEmphasizeType/templateContent/buttons 등 kapi add 필드 그대로 — 유형별 컬럼 분해 없이 JSON 단일 컬럼)');
$table->json('approved_content')->nullable()->comment('승인 확정 시점 content 스냅샷 (발송 SSoT — 승인 취소돼도 유지)');
$table->string('status', 20)->default('draft')->comment('검수 상태: draft/requested/approved/rejected/stopped/blocked/dormant (BizppurioTemplateStatus enum)');
$table->json('inspection_detail')->nullable()->comment('kapi detail 의 comments 배열 스냅샷 (반려 사유 원문)');
$table->timestamp('requested_at')->nullable()->comment('검수 신청 시각');
$table->timestamp('approved_at')->nullable()->comment('승인 확정 시각');
$table->timestamp('last_synced_at')->nullable()->comment('마지막 카카오 상태 동기화 시각');
$table->boolean('fallback_sms_enabled')->default(false)->comment('알림톡 실패 시 SMS 대체발송 여부 (resend 위임)');
$table->json('sms_body')->nullable()->comment('대체 SMS 겸 SMS 단독 본문의 다국어 맵 ({"ko": "...", "en": "..."}, #{var} 치환) — 수신자 로케일로 렌더');
$table->boolean('sms_only')->default(false)->comment('알림톡 없이 SMS 만 발송하는 알림 여부 (표시·알림톡 게이트용)');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->timestamps();
$table->unique('notification_type', 'bizppurio_template_type_unique');
$table->index('status', 'bizppurio_template_status_idx');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_templates', function (Blueprint $table) {
$table->comment('비즈뿌리오 알림 템플릿 (알림톡 등록·검수 상태 + SMS 본문)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_templates');
}
};
File diff suppressed because one or more lines are too long
@@ -4,15 +4,14 @@
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 7 · **엔드포인트 수**: 13
- **문서 수**: 6 · **엔드포인트 수**: 21
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 |
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 2 |
| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
| [report.md](report.md) | `report` | 1 |
| [templates.md](templates.md) | `templates` | 1 |
| [templates.md](templates.md) | `templates` | 14 |
| [token.md](token.md) | `token` | 1 |
| [webhook.md](webhook.md) | `webhook` | 1 |
@@ -7,58 +7,18 @@
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다
1. 이 문서는 알림톡 템플릿 작성 모달의 참조 조회(카테고리·발신프로필) 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
3. 실시간 템플릿 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클(templates.md)로 대체되어 제거됐습니다
4. kapi 실패는 422 + errors.bizppurio_message(카카오 사유 원문) 규약을 따릅니다
5. 갱신: 코드 변경 후 php artisan api:docgen 재실행
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 파라미터 | 위치 | 타입 | 필수 | 제약 | 설명 |
| --- | --- | --- | --- | --- | --- |
| status | query | string | 아니오 | max 30 | kapi `templateStatus` 필터 값(어휘는 kapi 정의를 따름) |
| keyword | query | string | 아니오 | max 50 | 템플릿명/코드 검색어 |
| page | query | integer | 아니오 | min 1 | 페이지 번호(기본 1) |
| count | query | integer | 아니오 | min 1 | 페이지당 건수(기본값은 서버 설정) |
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
작성 모달 참조 조회(카테고리·발신프로필) 전용 문서다. 알림톡 템플릿 작성 모달의 카테고리 셀렉트·발신프로필
셀렉트가 소비하는 kapi(카카오 관리 API) 조회를 프록시한다. 템플릿의 등록·검수·승인 라이프사이클 API 는
[templates.md](templates.md) 를 참조.
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories
@@ -82,11 +42,24 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| categories | array | `[{...}]` | kapi `/v3/kakao/template/category/all` 조회 결과 배열. 각 항목: `code`(카테고리 코드 — `content.categoryCode` 에 사용), `name`(소분류명), `groupName`(대분류명) |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"categories": [
{ "code": "001001", "name": "회원가입", "groupName": "회원" },
{ "code": "004001", "name": "구매완료", "groupName": "구매" }
]
}
}
```
**에러 응답**
@@ -97,7 +70,11 @@ Authorization: Bearer {YOUR_TOKEN}
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명**
템플릿 등록에 사용할 카카오 카테고리 전체(대분류·소분류)를 조회한다(`data.categories`). 작성 모달의
카테고리 셀렉트가 소비하며, 선택된 소분류 코드가 템플릿 생성/수정의 `content.categoryCode` 값이 된다.
kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles
@@ -121,97 +98,21 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) |
| 필드 | 타입 | 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| profiles | array | `[{...}]` | kapi `/v3/kakao/profile/use` 응답 `data.success` 배열(조회 실패 프로필 `fail` 은 제외). 각 항목: `senderKey`(발신프로필 키), `name`(프로필명), `status`, `block`, `dormant`, `categoryCode` 등 kapi 원형 필드 |
**응답 예시**
```json
{
"success": true,
"message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"data": { "cleared": 3 }
"message": "성공적으로 처리되었습니다.",
"data": {
"profiles": [
{ "senderKey": "05aa099bcbc5220a8c0b2...", "name": "@우리상점", "status": "A", "block": false, "dormant": false }
]
}
}
```
@@ -220,10 +121,11 @@ Authorization: Bearer {YOUR_TOKEN}
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다.
발신프로필(사용중) 상태 정보를 조회한다(`data.profiles`). 작성 모달의 발신프로필 셀렉트·상태 표시가
소비한다. kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
@@ -51,7 +51,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | array | `[]` | <!-- TODO: 설명 --> |
| results | array | `[]` | 요청한 `notification_log_ids` 각각의 비즈뿌리오 발송 결과. 해당 알림 로그로 발송된 건이 없으면 빈 배열 |
**응답 예시**
@@ -152,7 +152,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | <!-- TODO: 설명 --> |
| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | `notification_log_id` 를 키로 하는 결과 맵. 각 값은 `status`(pending/sent/failed) · `status_label`(현재 로케일 라벨) · `result_code`(비즈뿌리오 결과 코드) · 실패 사유를 담는다 |
**응답 예시**
@@ -324,6 +324,11 @@ HTTP/1.1 200
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명**
알림 발송 이력 화면이 최근 발송 건의 비즈뿌리오 결과를 한 번에 받아 결과 컬럼에 주입할 때 사용한다.
`lookup` 이 지정한 로그 ID 만 조회하는 것과 달리, 이 엔드포인트는 최근 구간을 서버가 정해 돌려주므로
화면이 조회 대상을 미리 알 필요가 없다. 결과는 `notification_log_id` 를 키로 하는 맵이며,
비즈뿌리오로 발송되지 않은 알림은 키 자체가 없다 — 화면은 키 부재를 "해당 없음" 으로 그린다.
@@ -1,196 +0,0 @@
# Notification Bindings API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| bindings | array | `[]` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"bindings": []
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다.
### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| notification_type | body | string | 예 | max 100 | <!-- TODO: 용도 --> |
| template_code | body | string | 아니오 | max 50 | <!-- TODO: 용도 --> |
| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) |
| fallback_sms_enabled | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"notification_type": "예시값",
"template_code": "예시값",
"template_name": "예시 이름",
"fallback_sms_enabled": true
}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.binding.saved",
"data": {
"bindings": {
"실측 예시값": {
"notification_type": "실측 예시값",
"template_code": "실측 예시값",
"template_name": "실측 예시값",
"fallback_sms_enabled": true
}
}
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) |
<!-- @generated:end -->
**설명**
알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다.
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다.
@@ -41,7 +41,7 @@ _단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| url | string | `http://g7-issue.eh.test/api/plugins/s…` | <!-- TODO: 설명 --> |
| url | string | `https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook` | 비즈뿌리오 콘솔에 등록할 발송 결과 리포트 수신 URL (현재 사이트 기준 절대 URL) |
**응답 예시**
File diff suppressed because it is too large Load Diff
@@ -27,15 +27,15 @@
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| DEVICE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
| CMSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
| MSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
| PHONE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
| MEDIA | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
| RESULT | body | string | 예 | max 10 | <!-- TODO: 용도 --> |
| REFKEY | body | string | 예 | max 32 | <!-- TODO: 용도 --> |
| TELRES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
| KAORES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
| DEVICE | body | string | 아니오 | max 20 | 발송 메시지 유형(SMS/LMS/MMS/AT 등 비즈뿌리오 코드) |
| CMSGID | body | string | 아니오 | max 64 | 비즈뿌리오 메시지 키(발송 요청 시 채번된 식별자) |
| MSGID | body | string | 아니오 | max 64 | 비즈뿌리오 내부 메시지 ID |
| PHONE | body | string | 아니오 | max 20 | 수신 전화번호 |
| MEDIA | body | string | 아니오 | max 10 | 실제 발송된 매체 유형(대체발송 시 요청 유형과 다를 수 있음) |
| RESULT | body | string | 예 | max 10 | 발송 결과 코드 — ResultCodeResolver 가 성공/실패와 사유로 해석 |
| REFKEY | body | string | 예 | max 32 | 발송 시 G7 이 부여한 참조 키 — 이 값으로 bizppurio_dispatches 행을 찾는다 |
| TELRES | body | string | 아니오 | max 10 | 대체발송(문자) 결과 코드 |
| KAORES | body | string | 아니오 | max 10 | 알림톡 발송 결과 코드 |
**요청 예시**
@@ -60,11 +60,21 @@ Content-Type: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
이 엔드포인트는 `data` 를 반환하지 않는다. 비즈뿌리오 URL PUSH 규약상 수신 사실만 알리면 되므로,
성공·실패와 무관하게 `success: true` + 수신 확인 메시지만 담은 200 봉투를 돌려준다.
리포트 해석 결과(성공/실패·사유)는 `bizppurio_dispatches` 행에 기록되며 응답에는 싣지 않는다.
**응답 예시**
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
Content-Type: application/json
{
"success": true,
"message": "발송 결과 리포트를 수신했습니다."
}
```
**에러 응답**
@@ -41,11 +41,11 @@ return [
'channels' => [
'source_label' => 'Bizppurio',
'sms' => [
'name' => 'SMS/LMS Text',
'name' => 'Bizppurio SMS',
'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.',
],
'alimtalk' => [
'name' => 'Kakao Alimtalk',
'name' => 'Bizppurio Alimtalk',
'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.',
],
],
@@ -82,27 +82,100 @@ return [
'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.',
'kakao_request_failed' => 'The Kakao management API request failed.',
'sender_key_missing' => 'Please set the alimtalk sender profile key first.',
'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)',
'image_unreadable' => 'The uploaded image file could not be read.',
],
// 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)',
'alimtalk_template_not_approved' => 'Skipped sending: no approved alimtalk template or alimtalk delivery is off. (notification type: :type)',
'sms_template_missing' => 'Skipped sending: no SMS body is configured. (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.',
'removed' => 'Alimtalk binding removed.',
// Notification template lifecycle (#597 — BizppurioTemplateController responses and state guards)
'template' => [
'created' => 'Notification template saved.',
'updated' => 'Notification template updated.',
'requested' => 'Inspection requested. The approval result syncs automatically, or use [Refresh] to check now.',
'request_cancelled' => 'Inspection request cancelled.',
'approval_cancelled' => 'Approval cancelled. Alimtalk delivery for this notification has stopped.',
'released' => 'Dormant state released.',
'synced' => 'Kakao inspection status synchronized.',
'deleted' => 'Notification template deleted.',
'image_uploaded' => 'Image uploaded.',
'content_locked' => 'The alimtalk content cannot be edited in the current state (:status). Cancel the inspection request first, or cancel the approval if approved.',
'content_missing' => 'Please compose the alimtalk template content first.',
'request_not_allowed' => 'Inspection cannot be requested in the current state (:status).',
'cancel_request_not_allowed' => 'The request cannot be cancelled because the template is not under inspection. (current: :status)',
'cancel_approval_not_allowed' => 'Approval cannot be cancelled because the template is not approved. (current: :status)',
'release_not_allowed' => 'The template is not dormant. (current: :status)',
'code_generation_failed' => 'Failed to allocate a template code. Please try again later.',
],
// Dispatch template content cache (AlimtalkTemplateController::clearCache response)
'cache' => [
'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.',
// Notification template validation (FormRequest — only documented limits; kapi is the final gate)
'validation' => [
'link_mo_required' => 'A web link (WL) button requires a mobile link.',
'link_and_required' => 'An app link (AL) button requires an Android scheme.',
'link_ios_required' => 'An app link (AL) button requires an iOS scheme.',
'tel_number_required' => 'A phone (TN) button requires a phone number.',
'plugin_id_required' => 'A plugin (P1–P3) button requires a plugin ID.',
'highlight_title_too_long' => 'The item highlight title must be at most :max characters.',
'highlight_description_too_long' => 'The item highlight description must be at most :max characters.',
'image_ratio_invalid' => 'The image must have a 2:1 width-to-height ratio. (e.g. 1000×500px)',
// Field labels used in validation messages (FormRequest::attributes)
'attributes' => [
'notification_type' => 'notification type',
'content' => 'AlimTalk content',
'template_name' => 'template name',
'message_type' => 'message type',
'emphasize_type' => 'emphasis type',
'template_content' => 'body',
'preview_message' => 'preview message',
'category_code' => 'category',
'security_flag' => 'secure template flag',
'extra' => 'supplementary information',
'title' => 'emphasis title',
'subtitle' => 'emphasis subtitle',
'header' => 'header',
'image_name' => 'image file name',
'image_url' => 'image URL',
'item' => 'item list',
'item_list' => 'items',
'item_title' => 'item title',
'item_description' => 'item description',
'summary' => 'summary',
'summary_title' => 'summary title',
'summary_description' => 'summary description',
'highlight' => 'highlight',
'highlight_title' => 'highlight title',
'highlight_description' => 'highlight description',
'highlight_image_url' => 'highlight image URL',
'represent_link' => 'representative link',
'buttons' => 'buttons',
'button_name' => 'button label',
'button_link_type' => 'button link type',
'quick_replies' => 'quick replies',
'quick_reply_name' => 'quick reply label',
'quick_reply_link_type' => 'quick reply link type',
'link_mo' => 'mobile link',
'link_pc' => 'PC link',
'link_and' => 'Android scheme',
'link_ios' => 'iOS scheme',
'tel_number' => 'phone number',
'plugin_id' => 'plugin ID',
'image' => 'image file',
'sms_body' => 'SMS body',
'alimtalk_enabled' => 'AlimTalk enabled',
'fallback_sms_enabled' => 'fallback SMS enabled',
'sms_only' => 'SMS only',
'is_active' => 'active',
'status' => 'status',
'search' => 'search keyword',
'page' => 'page',
'per_page' => 'items per page',
],
],
// Connection check (TokenCheckController response)
@@ -41,11 +41,11 @@ return [
'channels' => [
'source_label' => '비즈뿌리오',
'sms' => [
'name' => 'SMS/LMS 문자',
'name' => '비즈뿌리오 문자',
'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.',
],
'alimtalk' => [
'name' => '카카오 알림톡',
'name' => '비즈뿌리오 알림톡',
'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.',
],
],
@@ -82,27 +82,100 @@ return [
'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)',
'image_unreadable' => '업로드한 이미지 파일을 읽을 수 없습니다.',
],
// 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨)
'send_skipped' => [
'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)',
'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)',
'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
'alimtalk_template_not_approved' => '승인된 알림톡 템플릿이 없거나 알림톡 발송이 꺼져 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
'sms_template_missing' => 'SMS 본문이 설정되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)',
'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
],
// 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답)
'binding' => [
'saved' => '알림톡 연동을 저장했습니다.',
'removed' => '알림톡 연동을 해제했습니다.',
// 알림 템플릿 라이프사이클 (#597 — BizppurioTemplateController 응답·상태 가드)
'template' => [
'created' => '알림 템플릿을 저장했습니다.',
'updated' => '알림 템플릿을 수정했습니다.',
'requested' => '검수를 신청했습니다. 승인 결과는 자동 동기화되며 [새로고침]으로 즉시 확인할 수 있습니다.',
'request_cancelled' => '검수 신청을 취소했습니다.',
'approval_cancelled' => '승인을 취소했습니다. 이 알림의 알림톡 발송이 중단되었습니다.',
'released' => '휴면 상태를 해제했습니다.',
'synced' => '카카오 검수 상태를 동기화했습니다.',
'deleted' => '알림 템플릿을 삭제했습니다.',
'image_uploaded' => '이미지를 업로드했습니다.',
'content_locked' => '현재 상태(:status)에서는 알림톡 내용을 수정할 수 없습니다. 검수중이면 신청 취소를, 승인됨이면 승인 취소를 먼저 하세요.',
'content_missing' => '알림톡 템플릿 내용을 먼저 작성하세요.',
'request_not_allowed' => '현재 상태(:status)에서는 검수를 신청할 수 없습니다.',
'cancel_request_not_allowed' => '검수중 상태가 아니어서 신청을 취소할 수 없습니다. (현재: :status)',
'cancel_approval_not_allowed' => '승인 상태가 아니어서 승인을 취소할 수 없습니다. (현재: :status)',
'release_not_allowed' => '휴면 상태가 아니어서 해제할 수 없습니다. (현재: :status)',
'code_generation_failed' => '템플릿 코드를 채번하지 못했습니다. 잠시 후 다시 시도하세요.',
],
// 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답)
'cache' => [
'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
// 알림 템플릿 검증 (FormRequest — 문서 수치 명시 제약만, 그 외는 kapi 최종 게이트)
'validation' => [
'link_mo_required' => '웹링크(WL) 버튼은 모바일 링크가 필요합니다.',
'link_and_required' => '앱링크(AL) 버튼은 Android 스킴이 필요합니다.',
'link_ios_required' => '앱링크(AL) 버튼은 iOS 스킴이 필요합니다.',
'tel_number_required' => '전화(TN) 버튼은 전화번호가 필요합니다.',
'plugin_id_required' => '플러그인(P1~P3) 버튼은 플러그인 ID 가 필요합니다.',
'highlight_title_too_long' => '아이템 하이라이트 타이틀은 :max자 이하여야 합니다.',
'highlight_description_too_long' => '아이템 하이라이트 설명은 :max자 이하여야 합니다.',
'image_ratio_invalid' => '이미지는 가로:세로 2:1 비율이어야 합니다. (예: 1000×500px)',
// 검증 오류 문구에 쓰이는 필드 라벨 (FormRequest::attributes)
'attributes' => [
'notification_type' => '알림 유형',
'content' => '알림톡 내용',
'template_name' => '템플릿명',
'message_type' => '메시지 유형',
'emphasize_type' => '강조 유형',
'template_content' => '본문',
'preview_message' => '미리보기 문구',
'category_code' => '카테고리',
'security_flag' => '보안 템플릿 여부',
'extra' => '부가정보',
'title' => '강조 타이틀',
'subtitle' => '강조 서브타이틀',
'header' => '헤더',
'image_name' => '이미지 파일명',
'image_url' => '이미지 URL',
'item' => '아이템 리스트',
'item_list' => '아이템 목록',
'item_title' => '아이템 제목',
'item_description' => '아이템 설명',
'summary' => '요약',
'summary_title' => '요약 제목',
'summary_description' => '요약 설명',
'highlight' => '하이라이트',
'highlight_title' => '하이라이트 타이틀',
'highlight_description' => '하이라이트 설명',
'highlight_image_url' => '하이라이트 이미지 URL',
'represent_link' => '대표 링크',
'buttons' => '버튼',
'button_name' => '버튼명',
'button_link_type' => '버튼 링크 유형',
'quick_replies' => '바로연결',
'quick_reply_name' => '바로연결명',
'quick_reply_link_type' => '바로연결 링크 유형',
'link_mo' => '모바일 링크',
'link_pc' => 'PC 링크',
'link_and' => 'Android 스킴',
'link_ios' => 'iOS 스킴',
'tel_number' => '전화번호',
'plugin_id' => '플러그인 ID',
'image' => '이미지 파일',
'sms_body' => 'SMS 본문',
'alimtalk_enabled' => '알림톡 사용',
'fallback_sms_enabled' => '대체 SMS 사용',
'sms_only' => 'SMS 단독 발송',
'is_active' => '활성 여부',
'status' => '상태',
'search' => '검색어',
'page' => '페이지',
'per_page' => '페이지당 건수',
],
],
// 연결 확인 (TokenCheckController 응답)
@@ -8,7 +8,9 @@
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
"test:run": "vitest run",
"test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
"test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
},
"devDependencies": {
"@types/node": "^22.0.0",
@@ -151,6 +151,11 @@ class Plugin extends AbstractPlugin
* 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며
* 자동으로 복원된다.
*
* 반대로 우리 소유 테이블(bizppurio_templates·bizppurio_dispatches)은 여기서 손대지 않는다.
* 코어 PluginManager 가 "데이터도 함께 삭제" 를 선택했을 때만 rollbackMigrations() 로,
* 그것도 uninstall() 호출 **이전에** 제거하기 때문이다. 여기에 dropIfExists 를 더하면
* 테이블 정리 책임이 두 곳으로 갈라지고, 데이터 보존을 선택한 운영자의 테이블까지 지운다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
@@ -333,16 +338,6 @@ class Plugin extends AbstractPlugin
'sensitive' => true,
'required' => false,
],
'template_cache_minutes' => [
'type' => 'number',
'default' => 60,
'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'],
'hint' => [
'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.',
'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.',
],
'required' => false,
],
];
}
@@ -363,7 +358,6 @@ class Plugin extends AbstractPlugin
'api_key' => '',
'sender_number' => '',
'sender_key' => '',
'template_cache_minutes' => 60,
];
}
@@ -457,6 +451,26 @@ class Plugin extends AbstractPlugin
];
}
/**
* 스케줄 작업 목록 반환 (#597 §3.4).
*
* 검수중(requested) 알림톡 템플릿의 카카오 검수 상태를 30분 주기로 동기화한다.
* 커맨드는 requested 행이 없으면 카카오 API 를 호출하지 않으며, 화면의 수동
* [새로고침]이 동등 기능을 제공하므로 cron 미가동 환경에서도 승인 확인이 가능하다.
*
* @return array<int, array<string, string>> 스케줄 정의 목록
*/
public function getSchedules(): array
{
return [
[
'command' => 'bizppurio:sync-template-status',
'schedule' => 'everyThirtyMinutes',
'description' => '비즈뿌리오 알림톡 템플릿 검수 상태 동기화',
],
];
}
/**
* 확장 미들웨어 선언 (self-gate)
*
@@ -1,139 +1,800 @@
{
"extension_point": "notification_definition_row_footer",
"comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
"comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단(코어 notification_definition_row_footer 슬롯)에 알림톡 라이프사이클 줄 + SMS 설정 줄을 채운다(#597 §4.1). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽고, 작성/수정은 modal_bizppurio_template(오버레이 등록), SMS 본문은 modal_bizppurio_sms, 반려 사유는 modal_bizppurio_rejection, 승인 취소 경고는 modal_bizppurio_cancel_approval 을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
"components": [
{
"id": "bizppurio_row_binding",
"id": "bizppurio_row_lifecycle",
"type": "basic",
"name": "Div",
"if": "{{extensionPointProps.activeChannel === 'alimtalk'}}",
"props": {
"className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
"className": "mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 space-y-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-comment-dots",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label",
"props": {
"className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"text": "{{$t('sirsoft-message_bizppurio.template.status.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten'))}}",
"props": {
"className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
}
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"
},
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500 flex-shrink-0"
},
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}"
}
],
"props": {
"name": "fas fa-link",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
"className": "flex-center gap-2 min-w-0 flex-wrap"
}
},
{
"comment": "연결됨: 템플릿명(또는 코드)",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-200 truncate"
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}"
},
{
"comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0"
},
"name": "Div",
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" }
]
},
{
"comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled}}",
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "composite",
"name": "Toggle",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}",
"props": {
"checked": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false}}",
"size": "sm"
},
"actions": [
{
"type": "change",
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
"params": {
"method": "PUT",
"body": {
"alimtalk_enabled": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled ?? false)}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_enabled",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400"
},
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}"
}
],
"props": {
"className": "flex-center gap-1.5"
}
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_reject_view": {
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"comments": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}"
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_rejection"
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected' && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []).length > 0}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_tpl_upload": { "uploading": false, "error": null },
"bz_tpl_modal": {
"id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}",
"notification_type": "{{extensionPointProps.definition?.type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"status": "draft",
"inspection_detail": [],
"isSaving": false,
"error": null,
"sender_profile": "",
"content": {
"templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"templateMessageType": "BA",
"templateEmphasizeType": "NONE",
"templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}",
"templateExtra": "",
"templateHeader": "",
"templateTitle": "",
"templateSubtitle": "",
"templateImageName": "",
"templateImageUrl": "",
"categoryCode": "",
"buttons": [],
"quickReplies": [],
"templateItem": {
"list": [],
"summary": {
"title": "",
"description": ""
}
},
"templateItemHighlight": {
"title": "",
"description": "",
"imageUrl": ""
}
}
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_template"
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioCategories"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioProfiles"
}
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_compose",
"if": "{{!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) || !(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true)}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
"params": {
"method": "GET"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_tpl_upload": { "uploading": false, "error": null },
"bz_tpl_modal": {
"id": "{{response.data?.template?.id}}",
"notification_type": "{{response.data?.template?.notification_type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? response.data?.template?.notification_type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"status": "{{response.data?.template?.status}}",
"inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
"isSaving": false,
"error": null,
"sender_profile": "",
"content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_template"
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioCategories"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioProfiles"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft' || (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'rejected')}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_cancel_approval": {
"id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"isSaving": false
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_cancel_approval"
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'approved'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30",
"disabled": "{{_global.bz_row_busy ?? false}}"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_row_busy": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/request",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_row_busy": false
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.template.row.requested_toast",
"type": "success"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_row_busy": false
}
},
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
},
"if": "{{!(_global.bz_row_busy ?? false)}}"
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_request",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'draft'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/cancel-request",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast",
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'requested'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/release",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.template.row.released_toast",
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_release",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status) === 'dormant'}}"
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}/sync",
"params": {
"method": "POST"
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.template.row.synced_toast",
"type": "success"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh",
"if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code}}"
}
],
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-200 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_on" }
]
},
{
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && !(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_off" }
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-message_bizppurio.binding.unbound"
"className": "flex-center gap-1.5 flex-shrink-0 flex-wrap"
}
}
]
],
"props": {
"className": "flex-between gap-2"
}
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0"
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}",
"actions": [
"name": "Div",
"children": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal": {
"notification_type": "{{extensionPointProps.definition?.type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}",
"template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}",
"fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}",
"isSaving": false
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-comment-sms",
"size": "sm",
"className": "text-gray-400 dark:text-gray-500 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Label",
"comment": "알림톡 실패 시 SMS 대체발송 — 변경 즉시 저장(행이 없으면 draft 로 생성)",
"props": {
"className": "flex items-center gap-1.5 cursor-pointer select-none"
},
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"autoBinding": false,
"checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
"className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
},
"actions": [
{
"type": "change",
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
"params": {
"method": "PUT",
"body": {
"fallback_sms_enabled": "{{$event.target.checked}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-300"
}
}
]
},
{
"type": "basic",
"name": "Label",
"comment": "알림톡 없이 SMS 만 발송 — 변경 즉시 저장",
"props": {
"className": "flex items-center gap-1.5 cursor-pointer select-none"
},
{
"handler": "openModal",
"target": "modal_bizppurio_binding"
},
{
"comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioApprovedTemplates"
"children": [
{
"type": "basic",
"name": "Checkbox",
"props": {
"autoBinding": false,
"checked": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}",
"className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
},
"actions": [
{
"type": "change",
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}",
"params": {
"method": "PUT",
"body": {
"sms_only": "{{$event.target.checked}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioTemplates"
}
}
],
"onError": [
{
"handler": "toast",
"params": {
"message": "{{error.errors?.bizppurio_message ?? error.message}}",
"type": "error"
}
}
]
}
]
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.sms_only",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-300"
}
}
}
]
]
},
{
"type": "basic",
"name": "Span",
"comment": "SMS 본문 미리보기. 로케일 선택·폴백·유무 판정은 서버(BizppurioTemplate::getLocalizedSmsBody/hasSmsBody)가 하고 화면은 결과만 그린다 — 여기서 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다.",
"text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix')}} {{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').slice(0, 40) + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '').length > 40 ? '…' : '')}}",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 truncate min-w-0"
},
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}"
},
{
"type": "basic",
"name": "Span",
"text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500"
},
"if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}"
}
],
"props": {
"className": "flex-center gap-3 min-w-0 flex-wrap"
}
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bz_sms_modal": {
"notification_type": "{{extensionPointProps.definition?.type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}",
"editLang": "{{$locale}}",
"isSaving": false
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_sms"
}
]
}
}
],
"text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms"
}
]
],
"props": {
"className": "flex-between gap-2"
}
}
]
}
],
"priority": 320
}
}
@@ -0,0 +1,161 @@
// e2e:allow 업로드 in-flight 경합·모달 재시딩은 브라우저에서 재현하려면 네트워크 지연을
// 인위적으로 만들어야 한다. 브라우저가 담당하는 축(업로드 성공 → URL 기입 → 저장)은
// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 맡고, 여기서는 경합 자체를 고정한다.
/**
* uploadTemplateImage 핸들러 — in-flight 경합 가드 (#597 라운드 5 R2·R3)
*
* 라운드 5 이전에는 다음 두 경로가 열려 있었다:
* - 업로드가 진행 중인데 파일을 다시 고르면 두 요청이 겹치고, 먼저 끝난 쪽이
* `uploading:false` 를 써서 저장 버튼 잠금이 풀린다. 나중에 끝나는 업로드는
* 저장 이후에 templateImageUrl 을 덮는다.
* - 업로드 중 모달을 닫고 다른 알림의 모달을 열면(취소는 의도적으로 잠기지 않는다),
* 먼저 시작한 업로드의 결과가 새 모달의 폼에 기입된다. 실패 경로에서는 새 모달이
* 방금 시딩한 이미지 값이 지워진다.
*
* 둘 다 예외도 경고도 남기지 않으므로, 여기서 고정하지 않으면 되돌려도 red 가 나지 않는다.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { uploadTemplateImageHandler } from '../../handlers/uploadTemplateImage';
/** 전역 상태를 흉내내는 최소 스토어 (dot 경로 setState + getGlobal) */
function makeStore(initial: Record<string, any> = {}) {
const state: Record<string, any> = { bz_tpl_modal: { content: {} }, bz_tpl_upload: {}, ...initial };
const setGlobal = vi.fn((updates: Record<string, unknown>) => {
for (const [path, value] of Object.entries(updates)) {
const keys = path.split('.');
let cur: any = state;
for (const k of keys.slice(0, -1)) {
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
}
});
return { state, setGlobal, getGlobal: () => state };
}
/** change 이벤트 컨텍스트(파일 1개 선택) */
function fileContext(name = 'a.png') {
const input = { files: [new File(['x'], name, { type: 'image/png' })], value: name } as unknown as HTMLInputElement;
return { context: { event: { target: input } as unknown as Event }, input };
}
const ACTION = {
handler: 'sirsoft-message_bizppurio.uploadTemplateImage',
params: {
stateTarget: 'global',
statePathUrl: 'bz_tpl_modal.content.templateImageUrl',
statePathName: 'bz_tpl_modal.content.templateImageName',
statePathStatus: 'bz_tpl_upload',
},
} as any;
let store: ReturnType<typeof makeStore>;
beforeEach(() => {
store = makeStore();
(window as any).G7Core = {
state: { setGlobal: store.setGlobal, getGlobal: store.getGlobal },
t: (k: string) => k,
};
localStorage.setItem('auth_token', 'tkn');
});
afterEach(() => {
vi.restoreAllMocks();
delete (window as any).G7Core;
});
describe('uploadTemplateImage — in-flight 경합 가드', () => {
/**
* @effects upload_rejects_concurrent_selection_while_in_flight
*/
it('업로드가 진행 중이면 두 번째 파일 선택을 무시한다(요청 1회)', async () => {
let release: (v: any) => void = () => {};
const fetchMock = vi.fn(() => new Promise((r) => { release = r; }));
vi.stubGlobal('fetch', fetchMock);
const first = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
// 첫 요청이 아직 열려 있는 상태에서 두 번째 선택
await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any);
expect(fetchMock, '겹친 업로드가 나가면 먼저 끝난 쪽이 저장 잠금을 풀어 버린다').toHaveBeenCalledTimes(1);
release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/1.png' } }) });
await first;
expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/1.png');
expect(store.state.bz_tpl_upload.uploading).toBe(false);
});
/**
* @effects upload_result_discarded_when_modal_reseeded
*/
it('응답 도착 전 모달이 다시 시딩되면 성공 결과를 폼에 쓰지 않는다', async () => {
let release: (v: any) => void = () => {};
vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; })));
const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
expect(store.state.bz_tpl_upload.uploading).toBe(true);
// 다른 알림의 모달을 여는 지점이 하는 일: 상태 리시드
store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } });
store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' });
release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/stale.png' } }) });
await pending;
expect(store.state.bz_tpl_modal.content.templateImageUrl,
'이전 모달의 업로드 결과가 지금 열린 모달의 폼을 덮으면 안 된다').toBe('https://k/keep.png');
});
/**
* @effects upload_result_discarded_when_modal_reseeded
*/
it('응답 도착 전 모달이 다시 시딩되면 실패 결과도 새 모달의 값을 지우지 않는다', async () => {
let release: (v: any) => void = () => {};
vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; })));
const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } });
store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' });
release({ ok: false, json: async () => ({ success: false, message: '실패' }) });
await pending;
expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/keep.png');
expect(store.state.bz_tpl_upload.error, '새 모달에 남의 실패 배너가 뜨면 안 된다').toBeNull();
});
it('업로드가 끝나면 다음 선택을 다시 받는다(가드가 영구 잠기지 않는다)', async () => {
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: { url: 'https://k/1.png' } }),
}));
vi.stubGlobal('fetch', fetchMock);
await uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('성공 응답인데 url 이 비면 실패로 처리하고 이미지 값을 비운다', async () => {
store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/old.png' });
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => ({ success: true, data: { url: ' ' } }),
})));
await uploadTemplateImageHandler(ACTION, fileContext().context as any);
expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('');
expect(store.state.bz_tpl_upload.error).toBeTruthy();
expect(store.state.bz_tpl_upload.uploading).toBe(false);
});
});
@@ -1,309 +0,0 @@
/**
* 비즈뿌리오 메시징 플러그인 알림톡 템플릿 조회 탭 구조 검증 (조회 전용)
*
* plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 화면을 검증한다.
* 조회 전용 전환: 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만
* 남겼다. 등록·관리는 비즈뿌리오 콘솔로 위임한다.
* - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(query.tab)
* - 목록 서브뷰(상태필터·검색·상태배지·[내용] 버튼) — [새 템플릿]·폼·상태변경 없음
* - 상세 모달([닫기]만, 관리 액션 없음) / readiness 안내 / 준비 안내 / i18n 정합
*
* 독립 페이지·메뉴 없음: 설정 페이지 탭으로만 진입.
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, collectI18nKeys, type AnyNode } from './helpers';
const root = layout as unknown as AnyNode;
describe('alimtalk templates — 탭 네비게이션', () => {
it('탭 네비게이션에 환경설정·알림톡 템플릿 탭 버튼이 있다', () => {
const nav = findById(root, 'settings_tabs');
expect(nav).toBeTruthy();
expect(findById(nav, 'tab_connection')).toBeTruthy();
expect(findById(nav, 'tab_templates')).toBeTruthy();
});
it('알림톡 탭 버튼이 navigate replace 로 query.tab=templates 를 갱신하고 목록을 refetch 한다', () => {
const raw = JSON.stringify(findById(root, 'tab_templates'));
// 탭 전환은 navigate replace(+mergeQuery)로 if 재평가 → 화면 전환 (replaceUrl 은 if 미재평가라 부적합)
expect(raw).toContain('"handler":"navigate"');
expect(raw).toContain('"replace":true');
expect(raw).toContain('"tab":"templates"');
expect(raw).toContain('alimtalk_templates');
});
it('탭 패널이 query.tab 으로 배타 전환된다 (새로고침에도 유지)', () => {
const connection = findById(root, 'connection_tab_panel');
const templates = findById(root, 'templates_tab_panel');
expect((connection as { if?: string }).if).toContain("query.tab ?? 'connection') === 'connection'");
expect((templates as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
it('init_actions 가 templates 탭 진입 시 목록을 자동 로드한다 (새로고침 복원)', () => {
const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? [];
const refetch = inits.find((a) => a.handler === 'refetchDataSource');
expect(refetch).toBeTruthy();
expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
});
describe('alimtalk templates — 데이터소스', () => {
it('템플릿 목록 데이터소스가 admin API 를 조회한다 (조회 전용 — 카테고리 데이터소스 없음)', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const list = sources.find((s) => s.id === 'alimtalk_templates');
const cats = sources.find((s) => s.id === 'alimtalk_categories');
expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates');
expect(list?.auto_fetch).toBe(false);
// 카테고리 데이터소스는 등록 폼 전용이라 제거됨
expect(cats).toBeUndefined();
});
});
describe('alimtalk templates — 조회 전용 (폼·관리 제거)', () => {
it('폼 서브뷰(templates_form_view)가 제거되었다', () => {
expect(findById(root, 'templates_form_view')).toBeNull();
});
it('상태변경 확인/실행 모달(alimtalk_template_action_modal)이 제거되었다', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
expect(modals.find((m) => m.id === 'alimtalk_template_action_modal')).toBeUndefined();
});
it('폼 전환·관리 상태(templateView/templateForm/pendingAction/available_actions)가 레이아웃에 없다', () => {
const raw = JSON.stringify(root);
expect(raw).not.toContain('templateView');
expect(raw).not.toContain('templateForm');
expect(raw).not.toContain('pendingAction');
expect(raw).not.toContain('available_actions');
// 이미지 업로드 핸들러도 제거
expect(raw).not.toContain('uploadTemplateImage');
});
});
describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => {
it('상태 필터 Select 와 검색 입력이 있다', () => {
const toolbar = findById(root, 'templates_toolbar');
const raw = JSON.stringify(toolbar);
expect(raw).toContain('templateStatus');
expect(raw).toContain('templateKeyword');
});
it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => {
const raw = JSON.stringify(findById(root, 'templates_toolbar'));
expect(raw).toContain('templates.list.refresh');
// 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다.
expect(raw).not.toContain('cache/clear');
// 등록 진입(폼 전환) 없음
expect(raw).not.toContain('templates.list.new');
expect(raw).not.toContain('"form"');
});
it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => {
const card = findById(root, 'templates_table_card');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 표 헤더는 항상 존재
expect(raw).toContain('columns.name');
expect(raw).toContain('columns.status');
// 데이터 유무 분기 (빈 상태 안내 + 목록 iteration)
expect(raw).toContain('.length > 0');
expect(raw).toContain('.length === 0');
expect(raw).toContain('templates.list.empty');
});
it('표가 templates 목록을 iteration 으로 렌더한다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain('alimtalk_templates?.data?.templates');
expect(raw).toContain('"item_var":"tpl"');
expect(raw).toContain('tpl.templateName');
expect(raw).toContain('status_badge');
});
it('관리 컬럼이 [내용] 버튼 하나로 상세 조회 후 상세 모달을 연다 (상태별 액션 없음)', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
// 상세 조회 → 상세 모달
expect(raw).toContain('templates.actions.detail');
expect(raw).toContain('"handler":"apiCall"');
expect(raw).toContain('alimtalk_template_detail_modal');
// 관리 액션 메뉴(ActionMenu)·switch 분기 없음
expect(raw).not.toContain('ActionMenu');
expect(raw).not.toContain('"handler":"switch"');
expect(raw).not.toContain("id:'edit'");
expect(raw).not.toContain("id:'delete'");
});
it('목록에 번호·등록요청일·처리일 컬럼이 있다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain('columns.no');
expect(raw).toContain('columns.requested_at');
expect(raw).toContain('columns.processed_at');
// 순번은 pagination 기준 계산 + index_var
expect(raw).toContain('"index_var":"tplIndex"');
expect(raw).toContain('pagination?.current_page');
// 날짜는 kapi 원본 필드
expect(raw).toContain('tpl.createdAt');
expect(raw).toContain('tpl.modifiedAt');
});
it('상태 배지가 RDY 일 때만 세부(사용전)를 덧붙인다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain("tpl.service_status === 'RDY'");
expect(raw).toContain('status_sub.rdy');
});
it('페이지네이션이 page 상태를 갱신하고 목록을 refetch 한다', () => {
const listView = JSON.stringify(findById(root, 'templates_list_view'));
expect(listView).toContain('"name":"Pagination"');
expect(listView).toContain('onPageChange');
expect(listView).toContain('templatePage');
expect(listView).toContain('pagination?.total_page');
});
});
describe('alimtalk templates — 콘솔 안내 (조회 전용)', () => {
it('목록 상단 안내가 배지 의미(제목+배지명+설명) + 콘솔 위임 안내를 포함한다', () => {
const raw = JSON.stringify(findById(root, 'templates_list_notice'));
// 배지 의미 — 제목 + 배지명/설명 분리(의미색 라벨)
expect(raw).toContain('status_guide.title');
expect(raw).toContain('status_guide.sendable_label');
expect(raw).toContain('status_guide.inspecting_label');
expect(raw).toContain('status_guide.pending_label');
// 배지명은 의미색(초록/amber/빨강) solid 로 표의 배지와 매칭
expect(raw).toContain('text-green-700');
expect(raw).toContain('text-amber-700');
expect(raw).toContain('text-red-700');
// 콘솔 위임 안내 + 콘솔 링크
expect(raw).toContain('list_notice.console_desc');
expect(raw).toContain('list_notice.console_link');
expect(raw).toContain('bizppurio.com');
});
it('환경설정 탭에 사용 전 준비 안내(카카오·SMS 통합 1박스)가 있고 info_panel 은 제거되었다', () => {
const notice = findById(root, 'preparation_notice');
expect(notice).toBeTruthy();
const raw = JSON.stringify(notice);
// 통합 박스: 문자(발신번호) + 카카오(채널·템플릿·API키 3단계) + 콘솔 링크 (박스 제목 없이 채널 그룹만)
expect(raw).toContain('preparation.sms_label');
expect(raw).toContain('preparation.sms_sender');
expect(raw).toContain('preparation.kakao_label');
expect(raw).toContain('preparation.kakao_channel');
expect(raw).toContain('preparation.kakao_template');
expect(raw).toContain('preparation.kakao_apikey');
expect(raw).toContain('preparation.console_link');
// 제목·본문 sm 로 상향(가독성)
expect(raw).toContain('text-sm');
// 중복이던 연동정보 안내(info_panel)는 제거(운영전환 경고·리포트 안내는 폼/리포트 섹션이 담당)
expect(findById(root, 'info_panel')).toBeNull();
});
});
describe('alimtalk templates — 상세 모달 & readiness (조회 전용)', () => {
it('상세 모달이 정의되어 있다', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal');
expect(detail).toBeTruthy();
});
it('상세 모달이 상태 배지·내용을 표시하고 [닫기]만 노출한다 (관리 액션 없음)', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
const detail = JSON.stringify(modals.find((m) => m.id === 'alimtalk_template_detail_modal'));
// 상태 배지 + 내용(카테고리·유형·버튼) 표시
expect(detail).toContain('status_badge');
expect(detail).toContain('templates.detail.category');
expect(detail).toContain('templates.detail.buttons');
expect(detail).toContain('columns.requested_at');
// 닫기만 — 관리 액션(available_actions)·상태변경 모달 연결 없음
expect(detail).toContain('templates.detail.close');
expect(detail).not.toContain('available_actions');
expect(detail).not.toContain('alimtalk_template_action_modal');
});
it('readiness 안내가 미준비(ready=false) 시 조건부로 표시되고 항목별 상태를 노출한다', () => {
const readiness = findById(root, 'templates_readiness');
// 최상위 노출 조건은 ready 플래그 기반
const cond = (readiness as { if?: string }).if ?? '';
expect(cond).toContain('templates_readiness?.data');
expect(cond).toContain('ready');
// 자식 노드에서 항목별 미설정(api_key_set/sender_key_set) 조건부 표시
const raw = JSON.stringify(readiness);
expect(raw).toContain('api_key_set');
expect(raw).toContain('sender_key_set');
});
});
describe('alimtalk templates — 회귀', () => {
it('컴포넌트 최상위 actions 는 모두 이벤트 type 을 가진다 (charAt 회귀 방지)', () => {
// 회귀: 컴포넌트의 actions 배열 최상위 항목에 type(click 등) 이 없으면 엔진이
// getReactEventName 에서 eventType.charAt(0) 을 호출하다 "Cannot read properties of
// undefined (reading 'charAt')" 로 그 컴포넌트 렌더가 통째로 실패한다(PO 브라우저 검수로 발견).
const bad: string[] = [];
const walk = (node: AnyNode, path: string): void => {
if (Array.isArray(node)) {
node.forEach((n, i) => walk(n as AnyNode, `${path}[${i}]`));
return;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, unknown>;
// 컴포넌트의 actions 만 검사 (sequence/switch 내부 하위 actions 는 handler 를 가짐 → 제외)
if (Array.isArray(obj.actions) && obj.handler === undefined) {
(obj.actions as Array<Record<string, unknown>>).forEach((a, i) => {
if (a && typeof a === 'object' && a.type === undefined) {
bad.push(`${path}.actions[${i}] handler=${String(a.handler)}`);
}
});
}
for (const k of Object.keys(obj)) walk(obj[k] as AnyNode, `${path}.${k}`);
}
};
walk(root, 'root');
expect(bad, `type 누락 최상위 액션:\n${bad.join('\n')}`).toEqual([]);
});
});
describe('alimtalk templates — i18n 정합', () => {
it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
const keys = collectI18nKeys(layout);
const resolve = (dict: Record<string, unknown>, path: string): unknown =>
path.split('.').reduce<unknown>((acc, seg) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[seg];
}
return undefined;
}, dict);
for (const raw of keys) {
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
}
});
});
describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => {
it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => {
const card = findById(root, 'cache_section');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name)
expect(raw).toContain('settings.cache.section_title');
expect(raw).toContain('template_cache_minutes');
expect(raw).toContain('"number"');
expect(raw).toContain('settings.fields.template_cache_minutes.label');
expect(raw).toContain('settings.fields.template_cache_minutes.hint');
});
it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => {
const raw = JSON.stringify(findById(root, 'cache_section'));
// 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문
expect(raw).toContain('alimtalk-templates/cache/clear');
expect(raw).toContain('apiCall');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed');
});
});
@@ -118,3 +118,22 @@ export function collectI18nKeys(json: unknown): string[] {
const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
return Array.from(new Set(matches));
}
/**
* `{{...}}` 단일 바인딩 조건식을 주어진 자유변수 스코프로 실제 평가합니다.
*
* 문자열 포함/동일성 단언(`toContain`/`toBe`)은 조건을 잘못 고쳐도 기대 문자열을 함께
* 고치면 통과하므로 회귀를 잡지 못한다. 식을 그대로 실행해 결과 boolean 을 본다.
*
* @param expr `{{ ... }}` 형태의 조건식(감싸지 않은 식도 허용)
* @param scope 식이 참조하는 자유변수 (예: `{ _global: {...} }`)
* @returns 평가 결과의 boolean 캐스팅
*/
export function evalBinding(expr: string, scope: Record<string, unknown>): boolean {
const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
const names = Object.keys(scope);
// eslint-disable-next-line no-new-func
const fn = new Function(...names, `return (${body});`);
return Boolean(fn(...names.map((n) => scope[n])));
}
@@ -1,238 +0,0 @@
// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면
// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸.
/**
* 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2)
*
* 두 확장 파일로 분리 구현:
* - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals)
* + data_sources. target_layout=admin_settings.
* - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯
* (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼.
*
* 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용
* 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는
* errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당).
*
* 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선,
* 무오염(코어 저장 body 미개입), i18n 정합.
*/
import { describe, it, expect } from 'vitest';
import overlay from '../../../extensions/notification_tab_core.json';
import footer from '../../../extensions/notification_row_footer.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
const bannerRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []),
} as AnyNode;
const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
]));
};
describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => {
it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => {
expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => {
expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer');
expect((footer as Record<string, unknown>).target_layout).toBeUndefined();
});
it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
});
it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => {
const ds = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurioApprovedTemplates');
expect(ds?.auto_fetch).toBe(false);
expect(JSON.stringify(ds?.errorHandling)).toContain('suppress');
});
});
describe('binding UI — 상태 배너 + 안내 박스', () => {
it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(bannerRoot, 'bizppurio_status_banner');
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
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');
expect(raw).toContain('banner.setup_action');
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();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
});
describe('binding UI — 행 연결(extension_point)', () => {
it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
expect(row).toBeTruthy();
expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'");
});
it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]');
expect(raw).toContain('binding.unbound');
expect(raw).toContain('binding.btn_connect');
expect(raw).toContain('binding.btn_change');
});
it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurio_binding_modal');
expect(raw).toContain('"openModal"');
expect(raw).toContain('modal_bizppurio_binding');
});
it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
// 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고
expect(raw).toContain('is_unavailable === true');
expect(raw).toContain('binding.unavailable');
// 소실 경고는 red 배지로 표시(연결됨 초록과 구분)
expect(raw).toContain('bg-red-100');
});
it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
const raw = JSON.stringify(row);
const openIdx = raw.indexOf('"openModal"');
const refetchIdx = raw.indexOf('bizppurioApprovedTemplates');
expect(openIdx).toBeGreaterThan(-1);
expect(refetchIdx).toBeGreaterThan(-1);
expect(openIdx).toBeLessThan(refetchIdx);
});
});
describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => {
const modal = findById(overlayRoot, 'modal_bizppurio_binding');
it('연결 전용 모달이 modals 로 등록된다', () => {
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('binding.section_hint');
expect(raw).toContain('binding.connected_template');
expect(raw).toContain('binding.fallback_sms');
expect(raw).toContain('binding.variables_hint');
});
it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => {
const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body'));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
expect(raw).toContain('binding.save_error');
expect(raw).toContain('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
});
describe('binding UI — 드롭다운 조회 실패 vs 0건 구분(결함 3)', () => {
const modal = findById(overlayRoot, 'modal_bizppurio_binding');
it('승인 템플릿 데이터소스 fallback 에 load_failed:true 마커가 있다', () => {
const ds = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurioApprovedTemplates');
const fallback = (ds?.fallback as { data?: Record<string, unknown> })?.data ?? {};
// 조회 실패 시 이 마커가 상태에 실려 '0건'과 구분된다. 정상 응답에는 이 필드가 없다.
expect(fallback.load_failed).toBe(true);
expect(Array.isArray(fallback.templates)).toBe(true);
expect((fallback.templates as unknown[]).length).toBe(0);
});
it('드롭다운이 비었을 때 조회 실패(load_failed)면 설정 확인 문구를 노출한다', () => {
const raw = JSON.stringify(modal);
// 조회 실패 분기: length===0 && load_failed===true → templates_load_failed
expect(raw).toContain('binding.templates_load_failed');
expect(raw).toContain('load_failed === true');
});
it('드롭다운이 비었을 때 조회 정상(0건)이면 승인 템플릿 없음 문구를 노출한다', () => {
const raw = JSON.stringify(modal);
// 0건 분기: length===0 && !load_failed → no_approved_templates
expect(raw).toContain('binding.no_approved_templates');
expect(raw).toContain('!(bizppurioApprovedTemplates?.data?.load_failed)');
});
it('두 문구는 상호배타 조건이라 동시에 뜨지 않는다(조회실패=빨강 / 0건=amber)', () => {
const raw = JSON.stringify(modal);
// 조회 실패 문구는 red, 0건 문구는 amber 로 시각 구분
expect(raw).toContain('text-red-600');
expect(raw).toContain('text-amber-600');
});
});
describe('binding UI — 코어 무오염 + i18n', () => {
it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const all = JSON.stringify(overlay) + JSON.stringify(footer);
expect(all).not.toContain('/api/admin/notification-templates/');
expect(all).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)];
expect(keys.length).toBeGreaterThan(0);
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});
@@ -1,35 +1,36 @@
// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료
// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유).
// e2e:allow 3면 패리티는 정규화 문자열 대조 Vitest 로 잠근다(#597 재작성) — 라이프사이클 전이
// 브라우저 흐름은 코어 면 대표로 tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당.
/**
* 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속)
* 게시판·이커머스 알림 설정 '비즈뿌리오' 통합 탭 오버레이 — 3면 패리티 검증 (#597)
*
* 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json
* (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다.
* extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay
* injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다.
* 시나리오 매니페스트 exclusions 근거: "3면은 동일 오버레이 패턴(파일 diff 는 id·target 뿐)
* — 라이프사이클 전이는 코어 면으로 대표하고 면 axis 는 렌더 패리티(Vitest)로 잠근다".
*
* notification_tab_board.json / notification_tab_ecommerce.json 을 신설해
* target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다.
* notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다.
*/
* 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id·
* 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다.
* 반대로 데이터소스 id·모달 id·전역 상태 경로(bz_tpl_modal 등)는 치환 없이도 3면 동일해야
* 한다 — notification_row_footer.json(전역 매칭)이 이 이름들을 공유 참조하기 때문이다.
*
* 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 —
* core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는
* 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다.
*
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions,
* upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab
*/
import { describe, it, expect } from 'vitest';
import coreOverlay from '../../../extensions/notification_tab_core.json';
import boardOverlay from '../../../extensions/notification_tab_board.json';
import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
import { type AnyNode } from './helpers';
type OverlayFixture = {
label: string;
overlay: typeof boardOverlay;
targetLayout: string;
targetId: string;
bannerId: string;
notReadyId: string;
testModeId: string;
guideId: string;
modalBodyId: string;
idPrefix: string;
};
const FIXTURES: OverlayFixture[] = [
@@ -38,44 +39,44 @@ const FIXTURES: OverlayFixture[] = [
overlay: boardOverlay,
targetLayout: 'admin_board_settings',
targetId: 'board_notif_channel_content',
bannerId: 'bizppurio_board_status_banner',
notReadyId: 'bizppurio_board_banner_not_ready',
testModeId: 'bizppurio_board_banner_test_mode',
guideId: 'bizppurio_board_alimtalk_guide',
modalBodyId: 'bizppurio_board_binding_modal_body',
idPrefix: 'bizppurio_board_',
},
{
label: '이커머스',
overlay: ecommerceOverlay,
targetLayout: 'admin_ecommerce_settings',
targetId: 'ecommerce_notif_channel_content',
bannerId: 'bizppurio_ecommerce_status_banner',
notReadyId: 'bizppurio_ecommerce_banner_not_ready',
testModeId: 'bizppurio_ecommerce_banner_test_mode',
guideId: 'bizppurio_ecommerce_alimtalk_guide',
modalBodyId: 'bizppurio_ecommerce_binding_modal_body',
idPrefix: 'bizppurio_ecommerce_',
},
];
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
]));
/** comment 필드를 재귀 제거한다(설명문은 면마다 달라도 되는 유일한 자유 축). */
const stripComments = (node: unknown): unknown => {
if (Array.isArray(node)) return node.map(stripComments);
if (node && typeof node === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
if (k === 'comment') continue;
out[k] = stripComments(v);
}
return out;
}
return node;
};
describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({
overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId,
}) => {
const injectionRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? [])
.flatMap((i) => i.components ?? []),
} as AnyNode;
const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
const normalizedCore = JSON.stringify(stripComments(coreOverlay));
/** 3면 공유 이름(치환 없이 동일해야 하는 축) */
const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles'];
const SHARED_MODAL_IDS = [
'modal_bizppurio_template',
'modal_bizppurio_sms',
'modal_bizppurio_cancel_approval',
'modal_bizppurio_rejection',
];
const SHARED_GLOBAL_STATE_PATHS = ['bz_tpl_modal', 'bz_sms_modal', 'bz_cancel_approval', 'bz_reject_view', 'bz_tpl_upload'];
describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, overlay, targetLayout, targetId, idPrefix }) => {
it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => {
expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout);
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
@@ -88,80 +89,45 @@ describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({
expect(injections[0].position).toBe('prepend_child');
});
it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => {
it('면 고유 토큰(target_layout/target_id/id 접두)만 치환하면 코어 오버레이와 완전 동일하다(comment 제외)', () => {
const normalized = JSON.stringify(stripComments(overlay))
.split(targetLayout).join('admin_settings')
.split(targetId).join('notif_channel_content')
.split(idPrefix).join('bizppurio_');
expect(normalized, `${label} 오버레이가 코어와 구조 불일치`).toBe(normalizedCore);
});
it('데이터소스 id 3종이 코어와 완전 동일하다(순서 포함 — 행 footer 공유 참조)', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
expect(ids).toEqual(SHARED_DATA_SOURCE_IDS);
});
it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(injectionRoot, bannerId);
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
expect(cond).toContain('is_test_mode === true');
it('모달 id 4종이 코어와 완전 동일하다(행 footer 의 openModal target 공유)', () => {
const ids = ((overlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
expect(ids).toEqual(SHARED_MODAL_IDS);
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(injectionRoot, notReadyId));
expect(raw).toContain('banner.not_ready');
expect(raw).toContain('banner.setup_action');
expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
});
it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => {
const banner = findById(injectionRoot, testModeId);
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(injectionRoot, guideId);
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
expect(raw).toContain('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => {
const raw = JSON.stringify(findById(modalRoot, modalBodyId));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
it('전역 상태 경로(bz_*)가 접두 치환 없이 그대로 사용된다(면 전용 상태 분기 금지)', () => {
const raw = JSON.stringify(overlay);
expect(raw).not.toContain('/api/admin/notification-templates/');
expect(raw).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = collectPluginKeys(overlay);
expect(keys.length).toBeGreaterThan(0);
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
for (const path of SHARED_GLOBAL_STATE_PATHS) {
expect(raw, `${label} 에 ${path} 없음`).toContain(`_global.${path}`);
// 면 접두가 붙은 변형(bz_board_* 등)이 존재하면 footer 공유가 깨진다
expect(raw).not.toContain(path.replace('bz_', `bz_${idPrefix.replace('bizppurio_', '')}`));
}
});
});
describe('3면 패리티 — 공유 축 교차 검증', () => {
it('코어 오버레이도 동일한 공유 데이터소스·모달 id 집합을 갖는다(패리티 기준점 고정)', () => {
const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
const modalIds = ((coreOverlay as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS);
expect(modalIds).toEqual(SHARED_MODAL_IDS);
});
it('세 오버레이의 init_actions(탭 진입 요약 맵 조회)가 comment 제외 완전 동일하다', () => {
const pick = (o: unknown) => JSON.stringify(stripComments((o as { init_actions?: unknown }).init_actions ?? []));
expect(pick(boardOverlay)).toBe(pick(coreOverlay));
expect(pick(ecommerceOverlay)).toBe(pick(coreOverlay));
});
});
@@ -0,0 +1,691 @@
// e2e:allow 구조 단언 + 조건식 실평가 Vitest(#597) — 브라우저 흐름은
// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획).
//
// 행 하단 버튼의 노출 조건은 문자열 동일성이 아니라 `new Function` 실평가로 판정한다
// (§14.2 T7) — 리터럴 비교는 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이라 회귀를
// 잡지 못한다.
/**
* 알림 설정 '비즈뿌리오' 통합 탭 — 알림톡 템플릿 라이프사이클 UI 구조 검증 (#597)
*
* @effects row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type, sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab
*
* 두 확장 파일로 분리 구현:
* - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 모달 4종
* (작성/신청·SMS 본문·승인 취소·반려 사유) + data_sources 3종. target_layout=admin_settings.
* - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯
* (notification_definition_row_footer)에 알림톡 라이프사이클 줄 + SMS 설정 줄.
*
* 라이프사이클: 미작성(unwritten=행 없음/내용 없음) → 작성(draft) → 검수 신청(requested)
* → 승인(approved) / 반려(rejected) / 휴면(dormant). 발송 판정은 DB 가 유일한 근거.
*/
import { describe, it, expect } from 'vitest';
import coreOverlay from '../../../extensions/notification_tab_core.json';
import boardOverlay from '../../../extensions/notification_tab_board.json';
import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
import footer from '../../../extensions/notification_row_footer.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, findAllByName, type AnyNode, evalBinding } from './helpers';
const overlay = coreOverlay;
const overlayModalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
const bannerRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []),
} as AnyNode;
const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
/** 요약 맵의 def.type 행 접근 경로(행 하단 UI 전반이 공유하는 데이터 근거) */
const MAP_PATH = "bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]";
/** footer 에서 text 키로 버튼 노드를 찾는다. */
const findButtonByTextKey = (root: AnyNode, key: string): AnyNode | undefined =>
findAllByName(root, 'Button').find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`);
/** overlay 텍스트에서 $t:key 및 $t('key'...) 형태의 플러그인 i18n 정적 키를 모두 수집한다. */
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
// $t('key') / $t('key', {...}) 정적 호출 — 동적 접두('... .' + expr)는 끝의 '.' 로 걸러
// 접두 가족 검증(아래 별도 테스트)으로 넘긴다.
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'/g) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'$/, '')),
])).filter((k) => !k.endsWith('.'));
};
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint)', () => {
it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => {
expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => {
expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer');
expect((footer as Record<string, unknown>).target_layout).toBeUndefined();
});
});
describe('lifecycle UI — 데이터소스 3종', () => {
const sources = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? []);
it('bizppurioTemplates / bizppurioCategories / bizppurioProfiles 3종을 등록한다', () => {
expect(sources.map((d) => d.id)).toEqual([
'bizppurioTemplates',
'bizppurioCategories',
'bizppurioProfiles',
]);
});
it('3종 모두 auto_fetch:false — 설정 페이지 다른 탭에서 자동 호출되지 않는다', () => {
for (const ds of sources) {
expect(ds.auto_fetch, `${ds.id} auto_fetch`).toBe(false);
}
});
it('요약 맵은 GET templates/map 이고 탭 진입 init_actions 로만 조회된다', () => {
const map = sources.find((d) => d.id === 'bizppurioTemplates');
expect(map?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/map');
expect(map?.method).toBe('GET');
const inits = (overlay as { init_actions?: AnyNode[] }).init_actions ?? [];
const refetch = inits.find((a) => a.handler === 'refetchDataSource');
expect((refetch?.params as { dataSourceId?: string })?.dataSourceId).toBe('bizppurioTemplates');
});
it('카테고리·발신프로필은 실패를 suppress 하고 load_failed fallback 을 둔다(자격증명 미설정 422 방어)', () => {
for (const id of ['bizppurioCategories', 'bizppurioProfiles']) {
const ds = sources.find((d) => d.id === id);
expect(JSON.stringify(ds?.errorHandling), `${id} suppress`).toContain('suppress');
const fallback = (ds?.fallback as { data?: Record<string, unknown> })?.data ?? {};
expect(fallback.load_failed, `${id} load_failed`).toBe(true);
}
});
});
describe('lifecycle UI — 상태 배너 + 안내 박스', () => {
it('배너는 sms·alimtalk 탭 정체성에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(bannerRoot, 'bizppurio_status_banner');
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
expect(cond).toContain('is_test_mode === true');
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready'));
expect(raw).toContain('banner.not_ready');
expect(raw).toContain('banner.setup_action');
expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
});
it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다(회귀 유지)', () => {
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('알림톡 탭 상시 안내 박스가 라이프사이클 안내(tab_guide)를 노출한다', () => {
const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide');
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('template.tab_guide');
});
});
describe('lifecycle UI — 행 하단(extension_point) 라이프사이클 줄', () => {
const row = findById(footerRoot, 'bizppurio_row_lifecycle');
it('행 UI 는 activeChannel === alimtalk 일 때만 노출된다', () => {
expect(row).toBeTruthy();
expect((row as { if?: string }).if).toBe("{{extensionPointProps.activeChannel === 'alimtalk'}}");
});
it('상태 배지는 has_content 로 미작성(unwritten)/저장 상태를 분기해 template.status.* 로 표기한다', () => {
const badge = findAllByName(row as AnyNode, 'Span')
.find((s) => (s.text ?? '').includes("template.status.' +"));
expect(badge).toBeTruthy();
const text = badge?.text ?? '';
expect(text).toContain(`${MAP_PATH}?.has_content`);
expect(text).toContain("'unwritten'");
expect(text).toContain(`${MAP_PATH}?.status`);
});
it('[작성] 버튼: 행이 없거나 내용이 없을 때(if) → bz_tpl_modal seed + 작성 모달 open', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_compose');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{!(${MAP_PATH}) || !(${MAP_PATH}?.has_content === true)}}`);
const raw = JSON.stringify(btn);
expect(raw).toContain('"bz_tpl_modal"');
expect(raw).toContain('modal_bizppurio_template');
});
it('[수정] 버튼: draft·rejected 에서만(if) — 상세 GET 후 모달 open', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(
`{{(${MAP_PATH}?.has_content === true) && ((${MAP_PATH}?.status) === 'draft' || (${MAP_PATH}?.status) === 'rejected')}}`,
);
const raw = JSON.stringify(btn);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/');
expect(raw).toContain('"method":"GET"');
expect(raw).toContain('modal_bizppurio_template');
});
it('[수정(승인 취소)] 버튼: approved 에서만(if) — 승인 취소 경고 모달을 연다', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_approved');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'approved'}}`);
expect(JSON.stringify(btn)).toContain('modal_bizppurio_cancel_approval');
});
it('[검수 신청] 버튼: draft + 내용 보유에서만(if) — POST .../request 후 요약 맵 갱신', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_request');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{(${MAP_PATH}?.has_content === true) && (${MAP_PATH}?.status) === 'draft'}}`);
const raw = JSON.stringify(btn);
expect(raw).toContain('/request');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('bizppurioTemplates');
});
it('[신청 취소] 버튼: requested 에서만(if) — POST .../cancel-request', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_cancel_request');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'requested'}}`);
expect(JSON.stringify(btn)).toContain('/cancel-request');
});
it('[휴면 해제] 버튼: dormant 에서만(if) — POST .../release', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_release');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{(${MAP_PATH}?.status) === 'dormant'}}`);
expect(JSON.stringify(btn)).toContain('/release');
});
it('[새로고침] 버튼: template_code 보유 행에서만(if) — POST .../sync 수동 동기화', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_refresh');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(`{{(${MAP_PATH}) && ${MAP_PATH}?.template_code}}`);
expect(JSON.stringify(btn)).toContain('/sync');
});
it('[사유 보기] 버튼: rejected + 사유 보유에서만(if) — bz_reject_view seed 후 반려 모달 open', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_view_reason');
expect(btn).toBeTruthy();
expect(btn?.if).toBe(
`{{(${MAP_PATH}?.status) === 'rejected' && (${MAP_PATH}?.inspection_detail ?? []).length > 0}}`,
);
const raw = JSON.stringify(btn);
expect(raw).toContain('"bz_reject_view"');
expect(raw).toContain('modal_bizppurio_rejection');
});
it('알림톡 발송 토글은 내용 보유 행에서만 노출되고 delivery upsert 로 즉시 저장한다', () => {
const toggle = findAllByName(row as AnyNode, 'Toggle')[0];
expect(toggle).toBeTruthy();
expect(toggle?.if).toContain('has_content === true');
const raw = JSON.stringify(toggle);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}');
expect(raw).toContain('"method":"PUT"');
expect(raw).toContain('alimtalk_enabled');
});
});
describe('lifecycle UI — SMS 설정 줄(체크박스 + 본문 미리보기)', () => {
const row = findById(footerRoot, 'bizppurio_row_lifecycle');
const checkboxes = findAllByName(row as AnyNode, 'Checkbox');
it.each([
['fallback_sms_enabled', '대체 SMS'],
['sms_only', 'SMS 단독'],
])('%s 체크박스: autoBinding:false + checked !! 강제 + $event.target.checked 를 delivery PUT body 로 전송', (field) => {
const cb = checkboxes.find((c) => String((c.props as { checked?: string })?.checked ?? '').includes(field));
expect(cb).toBeTruthy();
expect((cb?.props as { autoBinding?: boolean })?.autoBinding).toBe(false);
expect((cb?.props as { checked?: string })?.checked).toBe(`{{!!(${MAP_PATH}?.${field})}}`);
const change = (cb?.actions ?? []).find((a) => a.type === 'change') as AnyNode;
expect(change?.handler).toBe('apiCall');
expect(change?.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{extensionPointProps.definition?.type}}');
const params = change?.params as { method?: string; body?: Record<string, string> };
expect(params?.method).toBe('PUT');
expect(params?.body?.[field]).toBe('{{$event.target.checked}}');
});
it('SMS 본문 미리보기(40자 절단)와 미설정 문구가 상호 조건으로 존재한다', () => {
const raw = JSON.stringify(row);
expect(raw).toContain('sms_body_prefix');
expect(raw).toContain('.slice(0, 40)');
expect(raw).toContain('template.row.sms_body_missing');
});
it('[SMS 본문 편집] 버튼이 bz_sms_modal seed 후 SMS 모달을 연다', () => {
const btn = findButtonByTextKey(row as AnyNode, 'template.row.btn_edit_sms');
expect(btn).toBeTruthy();
const raw = JSON.stringify(btn);
expect(raw).toContain('"bz_sms_modal"');
expect(raw).toContain('modal_bizppurio_sms');
});
});
describe('lifecycle UI — 작성/신청 모달(modal_bizppurio_template)', () => {
const modal = findById(overlayModalRoot, 'modal_bizppurio_template');
it('작성 모달이 3면(코어/게시판/이커머스) 오버레이 모두에 등록된다(행 footer 가 공유 참조)', () => {
for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) {
const ids = ((o as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
expect(ids, `${label} 오버레이 modals`).toContain('modal_bizppurio_template');
}
});
it('저장 버튼은 id 유무로 POST(신규)/PUT(기존) 2벌 if 분기다', () => {
const saves = findAllByName(modal as AnyNode, 'Button')
.filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save');
expect(saves).toHaveLength(2);
const create = saves.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}');
const update = saves.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}');
expect(create).toBeTruthy();
expect(update).toBeTruthy();
const createRaw = JSON.stringify(create);
expect(createRaw).toContain('"/api/plugins/sirsoft-message_bizppurio/admin/templates"');
expect(createRaw).toContain('"method":"POST"');
expect(createRaw).toContain('notification_type');
const updateRaw = JSON.stringify(update);
expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}');
expect(updateRaw).toContain('"method":"PUT"');
});
it('[저장 후 검수 신청] 버튼은 저장 onSuccess 로 request 엔드포인트를 체인한다(신규/기존 2벌)', () => {
const saveRequests = findAllByName(modal as AnyNode, 'Button')
.filter((b) => b.text === '$t:sirsoft-message_bizppurio.template.form.btn_save_request');
expect(saveRequests).toHaveLength(2);
const create = saveRequests.find((b) => b.if === '{{!(_global.bz_tpl_modal?.id)}}');
const update = saveRequests.find((b) => b.if === '{{_global.bz_tpl_modal?.id}}');
// 신규: POST 저장 응답의 id 로 request
const createRaw = JSON.stringify(create);
expect(createRaw).toContain('"method":"POST"');
expect(createRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request');
// 기존: PUT 저장 후 자기 id 로 request
const updateRaw = JSON.stringify(update);
expect(updateRaw).toContain('"method":"PUT"');
expect(updateRaw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request');
});
it('강조 유형 조건부 섹션: TEXT(타이틀/서브타이틀)·IMAGE(업로드)·ITEM_LIST(아이템) 이 if 로 전환된다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'");
expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'");
expect(raw).toContain("_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'");
// TEXT 섹션 필드
expect(raw).toContain('templateTitle');
expect(raw).toContain('templateSubtitle');
});
it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'");
});
it('버튼 그룹은 최대 5개, 바로연결은 최대 10개에서 추가 버튼이 disabled 된다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}"');
expect(raw).toContain('"{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}"');
});
it('이미지 업로드는 커스텀 핸들러(uploadTemplateImage)로 URL 을 상태에 기입한다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('sirsoft-message_bizppurio.uploadTemplateImage');
expect(raw).toContain('bz_tpl_modal.content.templateImageUrl');
});
});
describe('lifecycle UI — 승인 취소·반려·SMS 모달', () => {
it('승인 취소 모달: 경고 문구(cancel_approval.warning) + POST .../cancel-approval 배선', () => {
const modal = findById(overlayModalRoot, 'modal_bizppurio_cancel_approval');
expect(modal).toBeTruthy();
const raw = JSON.stringify(modal);
expect(raw).toContain('template.cancel_approval.warning');
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval');
expect(raw).toContain('"method":"POST"');
});
it('반려 사유 모달: inspection_detail 스냅샷(comments)을 반복 렌더한다', () => {
const modal = findById(overlayModalRoot, 'modal_bizppurio_rejection');
expect(modal).toBeTruthy();
const raw = JSON.stringify(modal);
expect(raw).toContain('_global.bz_reject_view?.comments');
expect(raw).toContain('template.rejection.modal_title');
});
it('SMS 본문 모달: delivery upsert(PUT) 로 sms_body 를 저장한다', () => {
const modal = findById(overlayModalRoot, 'modal_bizppurio_sms');
expect(modal).toBeTruthy();
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}');
expect(raw).toContain('"method":"PUT"');
expect(raw).toContain('sms_body');
});
});
describe('lifecycle UI — 코어 무오염 + i18n 정합', () => {
it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const all = JSON.stringify(overlay) + JSON.stringify(footer);
expect(all).not.toContain('/api/admin/notification-templates/');
expect(all).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 정적 키가 ko·en 에 존재한다', () => {
const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)];
expect(keys.length).toBeGreaterThan(0);
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
it("동적 접두 'template.status.' 가족: ko·en 에 상태 8종 키가 모두 존재한다", () => {
const statuses = ['unwritten', 'draft', 'requested', 'approved', 'rejected', 'stopped', 'blocked', 'dormant'];
for (const dict of [ko, en] as Array<Record<string, any>>) {
expect(Object.keys(dict.template.status).sort()).toEqual([...statuses].sort());
}
});
it("동적 접두 'templates.link_type.' 가족: 모달이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => {
// 버튼 셀렉트 13종 + 바로연결 셀렉트 6종(부분집합)
const used = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP'];
for (const dict of [ko, en] as Array<Record<string, any>>) {
for (const code of used) {
expect(dict.templates.link_type[code], `link_type.${code}`).toBeTruthy();
}
}
});
});
describe('lifecycle UI — 바인딩 브레이스 위생 (회귀: 프리필 끝 `}}` 잔재)', () => {
it('문자열 값 어디에도 4연속 브레이스(}}}}·{{{{)가 없다', () => {
// 회귀 배경(#597 브라우저 실측): 작성 모달 본문 프리필 표현식이 `}}}}` 로 끝나
// 엔진이 바인딩을 조기 종료하고 잔여 `}}` 가 본문 값에 리터럴로 붙었다.
// 구조적 중첩 브레이스(minified JSON)와 구분하기 위해 문자열 리프만 검사한다.
const collectStrings = (node: unknown, acc: string[] = []): string[] => {
if (typeof node === 'string') acc.push(node);
else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc));
else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc));
return acc;
};
for (const [name, json] of [
['core', coreOverlay],
['board', boardOverlay],
['ecommerce', ecommerceOverlay],
['footer', footer],
] as const) {
const offenders = collectStrings(json).filter((v) => v.includes('}}}}') || v.includes('{{{{'));
expect(offenders, `${name} 브레이스 잔재: ${offenders.join(' | ')}`).toEqual([]);
}
});
it('표현식 $t 를 2-인자(파라미터 객체)로 호출하지 않는다 — 엔진은 단일 인자만 지원', () => {
// 회귀 배경(#597 §6.3 실측): `$t('key', {date: …})` 의 두 번째 인자는 엔진이
// 무시해 "{date} 신청" 플레이스홀더 원문이 화면에 노출됐다. 파라미터 치환은
// 텍스트 문법 `$t:key|param={{expr}}` 만 지원한다 (선례: admin_activity_log_list).
const collectStrings = (node: unknown, acc: string[] = []): string[] => {
if (typeof node === 'string') acc.push(node);
else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc));
else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc));
return acc;
};
for (const [name, json] of [
['core', coreOverlay],
['board', boardOverlay],
['ecommerce', ecommerceOverlay],
['footer', footer],
] as const) {
const offenders = collectStrings(json).filter((v) => /\$t\([^)]*,\s*\{/.test(v));
expect(offenders, `${name} 2-인자 $t 호출(파라미터 미치환 원문 노출): ${offenders.join(' | ')}`).toEqual([]);
}
});
});
describe('lifecycle UI — 행 하단 버튼 노출 조건 실평가 (#597 §14.2 T7)', () => {
const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode;
/**
* `{{...}}` 단일 바인딩 형태의 조건식을 실제로 평가한다.
*
* 문자열 동일성 단언(`expect(btn.if).toBe('{{...}}')`)은 표현식이 바뀌면 테스트도 같이
* 바뀌므로 회귀를 잡지 못한다 — 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이다.
* 여기서는 식을 그대로 실행해 상태별 노출 결과를 판정한다.
*/
const evaluateIf = (expr: string, templates: Record<string, unknown>, type: string): boolean => {
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`);
return Boolean(fn({ data: { templates } }, { definition: { type }, activeChannel: 'alimtalk' }));
};
/** 상태별 요약 맵 행 (bizppurioTemplates.data.templates[type]) */
const ROWS: Record<string, Record<string, unknown> | undefined> = {
'행 없음': undefined,
'행만 있고 내용 없음': { has_content: false, status: 'draft' },
draft: { has_content: true, status: 'draft', template_code: null },
requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' },
approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' },
rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] },
'반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] },
dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' },
stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' },
blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' },
};
/** 버튼 i18n 키 → 그 버튼이 보여야 하는 상태 이름 집합 */
const EXPECTED_VISIBLE: Record<string, string[]> = {
'template.row.btn_compose': ['행 없음', '행만 있고 내용 없음'],
'template.row.btn_edit': ['draft', 'rejected', '반려(사유 없음)'],
'template.row.btn_edit_approved': ['approved'],
'template.row.btn_request': ['draft'],
'template.row.btn_cancel_request': ['requested'],
'template.row.btn_release': ['dormant'],
'template.row.btn_refresh': ['requested', 'approved', 'rejected', '반려(사유 없음)', 'dormant', 'stopped', 'blocked'],
'template.row.btn_view_reason': ['rejected'],
};
it.each(Object.keys(EXPECTED_VISIBLE))('%s 의 노출 조건이 상태별로 정확히 평가된다', (key) => {
const btn = findButtonByTextKey(row, key);
expect(btn, `${key} 버튼이 레이아웃에 없다`).toBeTruthy();
const expr = (btn as { if?: string }).if;
expect(expr, `${key} 에 if 조건이 없다`).toBeTruthy();
const expected = EXPECTED_VISIBLE[key];
for (const [state, rowValue] of Object.entries(ROWS)) {
const templates = rowValue === undefined ? {} : { welcome: rowValue };
const actual = evaluateIf(expr as string, templates, 'welcome');
expect(actual, `${key} @ ${state}: ${expected.includes(state) ? '노출' : '숨김'} 이어야 한다`)
.toBe(expected.includes(state));
}
});
it('상태 10종 × 버튼 8종을 모두 평가한다 (커버리지 하한 고정)', () => {
expect(Object.keys(ROWS)).toHaveLength(10);
expect(Object.keys(EXPECTED_VISIBLE)).toHaveLength(8);
});
});
/**
* @effects upload_in_progress_locks_save_buttons_not_cancel
*/
describe('lifecycle UI — 이미지 업로드 상태 배선 (#597 §14.4·§14.5 V2)', () => {
const modalRaw = JSON.stringify(overlayModalRoot);
it('업로드 진행 중에는 저장 계열 버튼이 잠기고, 취소는 잠기지 않는다', () => {
const buttons = findAllByName(overlayModalRoot, 'Button');
const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/.test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', '')));
expect(saveButtons.length, '작성 모달의 저장 계열 버튼(신규/수정 × 저장/저장후신청) 4개').toBe(4);
// 문자열 포함 확인이 아니라 두 상태를 실제로 태운다 — `... && false` 로 바꿔도
// toContain 은 통과하지만 아래 평가는 red 가 된다.
for (const b of saveButtons) {
const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
'업로드 in-flight 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true);
expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }),
'업로드가 끝났는데도 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false);
}
const cancel = buttons.find((b) => b.text === '$t:common.cancel');
const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? '');
expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
'취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false);
});
it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => {
expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''");
});
});
describe('lifecycle UI — SMS 본문 다국어 (#597 §14.3)', () => {
const modalRaw = JSON.stringify(overlayModalRoot);
it('SMS 모달은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => {
const tabs = findById(overlayModalRoot, 'bz_sms_lang_tabs');
expect(tabs, 'SMS 본문 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy();
expect(JSON.stringify(tabs)).toContain('{{$locales}}');
expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]');
});
it('SMS 본문 저장은 로케일 맵을 통째로 전송한다 (문자열 아님)', () => {
expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"');
});
it('변수 삽입 대상 경로가 현재 편집 로케일을 가리킨다', () => {
expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)");
});
});
describe('compose modal — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => {
/**
* `{{...}}` 조건식을 모달 폼 상태에 대해 실제로 평가한다 (바인딩 프로브).
*
* 화면을 렌더하지 않고도 "이 상태에서 이 블록이 보이는가 / 이 버튼이 잠기는가" 를
* 판정할 수 있다 — 조건식이 소비하는 자유변수는 `_global` 하나뿐이기 때문이다.
* substring 존재 확인은 조건을 잘못 고쳐도 통과하므로 회귀를 잡지 못한다.
*/
const evalWithModal = (expr: string, modal: Record<string, unknown>): boolean => {
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
const fn = new Function('_global', `return (${body});`);
return Boolean(fn({ bz_tpl_modal: modal, bz_tpl_upload: {} }));
};
/** 강조유형별 조건부 블록의 if 식을 레이아웃에서 수집한다. */
const conditionalBlocks = (): Record<string, string> => {
const found: Record<string, string> = {};
const walk = (node: unknown): void => {
if (Array.isArray(node)) { node.forEach(walk); return; }
if (!node || typeof node !== 'object') return;
const rec = node as Record<string, unknown>;
const cond = typeof rec.if === 'string' ? rec.if : '';
const m = cond.match(/templateEmphasizeType === '(TEXT|IMAGE|ITEM_LIST)'/);
if (m && !found[m[1]]) found[m[1]] = cond;
Object.values(rec).forEach(walk);
};
walk(overlayModalRoot);
return found;
};
it.each(['TEXT', 'IMAGE', 'ITEM_LIST'])('강조유형 %s 블록은 그 유형에서만 노출된다', (type) => {
const blocks = conditionalBlocks();
const expr = blocks[type];
expect(expr, `${type} 조건부 블록이 모달에 없다`).toBeTruthy();
for (const current of ['NONE', 'TEXT', 'IMAGE', 'ITEM_LIST']) {
const visible = evalWithModal(expr, { content: { templateEmphasizeType: current } });
expect(visible, `강조유형 ${current} 일 때 ${type} 블록은 ${current === type ? '노출' : '숨김'}`)
.toBe(current === type);
}
});
it('강조유형 미선택(undefined) 상태에서는 조건부 블록이 모두 숨는다', () => {
const blocks = conditionalBlocks();
for (const [type, expr] of Object.entries(blocks)) {
expect(evalWithModal(expr, { content: {} }), `${type} 블록`).toBe(false);
}
});
/** 반복 입력의 "추가" 버튼 상한 조건 — [상태 경로, 상한] */
const REPEAT_CAPS: Array<[string, number]> = [
['templateItem?.list', 10],
['buttons', 5],
['quickReplies', 10],
];
it.each(REPEAT_CAPS)('반복 입력 %s 는 상한 %i 에 도달하면 추가가 잠긴다', (pathFragment, cap) => {
const buttons = findAllByName(overlayModalRoot, 'Button');
const addBtn = buttons.find((b) => {
const d = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
return d.includes(pathFragment) && d.includes(`>= ${cap}`);
});
expect(addBtn, `${pathFragment} 상한 ${cap} 을 거는 추가 버튼이 없다`).toBeTruthy();
const expr = String((addBtn?.props as { disabled?: string }).disabled);
const key = pathFragment.includes('templateItem') ? 'templateItem' : pathFragment;
const makeContent = (n: number): Record<string, unknown> => {
const arr = Array.from({ length: n }, () => ({}));
return key === 'templateItem' ? { templateItem: { list: arr } } : { [key]: arr };
};
// 경계 3점: 상한-1(열림) / 상한(잠김) / 상한+1(잠김)
expect(evalWithModal(expr, { content: makeContent(cap - 1) }), `${cap - 1}개: 열림`).toBe(false);
expect(evalWithModal(expr, { content: makeContent(cap) }), `${cap}개: 잠김`).toBe(true);
expect(evalWithModal(expr, { content: makeContent(cap + 1) }), `${cap + 1}개: 잠김`).toBe(true);
// 비어 있을 때(키 자체 부재)도 열려 있어야 한다 — ?? [] fallback 회귀 방지
expect(evalWithModal(expr, { content: {} }), '항목 0개: 열림').toBe(false);
});
it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다', () => {
/**
* 기대값을 조건식에서 역산하지 않는다 — `linkType === 'X'` 를 조건식 자신에서 뽑아
* 그 X 로 참이 되는지 보는 형태는 어떤 등가식이든 무조건 통과하는 준-항등식이고,
* "어느 입력칸이 어느 linkType 의 필드인가" 를 전혀 고정하지 못한다.
* 대응표는 부록 A-3(WL→linkMo/linkPc, AL→linkAnd+linkIos, TN→telNumber,
* P1~P3→pluginId)에서 가져와 **필드 바인딩 기준으로** 적는다.
*/
const EXPECTED_FOR_FIELD: Record<string, string[]> = {
linkMo: ['WL'],
linkPc: ['WL'],
linkAnd: ['AL'],
linkIos: ['AL'],
telNumber: ['TN'],
pluginId: ['P1', 'P2', 'P3'],
};
const ALL_LINK_TYPES = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP'];
const inputs = findAllByName(overlayModalRoot, 'Input')
.filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? '')));
// 조건식이 아니라 value 바인딩으로 필드 정체를 식별한다.
const seen = new Set<string>();
for (const node of inputs) {
const value = String((node.props as { value?: string } | undefined)?.value ?? '');
const field = (value.match(/buttonsItem\.([A-Za-z]+)/) ?? [])[1];
expect(field, `value 바인딩에서 필드명을 못 읽었다: ${value}`).toBeTruthy();
const expected = EXPECTED_FOR_FIELD[field as string];
expect(expected, `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy();
seen.add(field as string);
const cond = String((node as { if?: string }).if);
for (const lt of ALL_LINK_TYPES) {
expect(evalBinding(cond, { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`)
.toBe(expected!.includes(lt));
}
}
// 부록 A-3 의 조건부 필드가 하나라도 화면에서 사라지면 red (하한 단언이 아니라 전수 대조)
expect([...seen].sort()).toEqual(Object.keys(EXPECTED_FOR_FIELD).sort());
});
});
@@ -162,7 +162,7 @@ describe('plugin_settings.json — 연결 확인 (§529)', () => {
expect(field).not.toBe(findById(root, 'field_password'));
});
it('연결 확인 버튼이 캐시 초기화 버튼과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => {
it('연결 확인 버튼이 설정 행과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => {
const field = findById(root, 'field_connection_check');
const raw = JSON.stringify(field);
expect(raw).toContain('grid-cols-1');
@@ -346,8 +346,9 @@ describe('plugin_settings.json — 저장 버튼', () => {
const allowed = [
'apiCall', 'setState', 'toast', 'navigate', 'sequence', 'switch',
'refetchDataSource', 'scrollIntoView', 'copyToClipboard',
'openModal', 'closeModal', 'replaceUrl',
'openModal', 'closeModal', 'replaceUrl', 'suppress',
'sirsoft-message_bizppurio.uploadTemplateImage',
'sirsoft-message_bizppurio.insertVariable',
];
for (const h of handlers) {
expect(allowed).toContain(h);
@@ -409,28 +410,26 @@ describe('plugin_settings.json — 탭 전환', () => {
});
});
describe('plugin_settings.json — 목록 조회 실패 표시', () => {
it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => {
describe('plugin_settings.json — templates 탭 readiness 게이트 (#597 재편)', () => {
// 구 조회 전용 목록(alimtalk_templates + templateListError 배너)은 #597 에서
// DB 목록 관리 화면(bizppurio_templates_list)으로 교체됐다. 관리 화면 상세 배선은
// plugin_settings_manage.test.tsx 가 담당하고, 여기서는 탭 골격만 잠근다.
it('templates_readiness 데이터소스가 templates 탭에서만 조회된다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const ds = sources.find((s) => s.id === 'alimtalk_templates') as
const ds = sources.find((s) => s.id === 'templates_readiness') as
| Record<string, unknown>
| undefined;
expect(ds).toBeTruthy();
const onError = JSON.stringify(ds?.onError ?? {});
// 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백
expect(onError).toContain('templateListError');
expect(onError).toContain('kakao_message');
expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness');
expect(ds?.if).toContain("query.tab ?? 'connection') === 'templates'");
});
it('목록 오류 배너가 오류 존재 + 준비완료(ready) 조건으로 존재한다', () => {
const banner = findById(layout, 'templates_list_error') as
| Record<string, unknown>
| undefined;
expect(banner).toBeTruthy();
// 키 미설정(ready=false)일 때는 readiness 안내 배너가 담당 → 빨간 오류 배너는 숨겨
// 두 배너가 동시에 뜨지 않게 한다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우만 노출.
expect(banner?.if).toBe('{{_local.templateListError && templates_readiness?.data?.ready}}');
// 사유 본문(카카오 실제 사유)을 그대로 렌더한다
expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}');
it('readiness 안내(!ready)와 관리 뷰(ready)가 상호배타로 존재한다', () => {
const notice = findById(layout, 'templates_readiness') as Record<string, unknown> | null;
const manageView = findById(layout, 'templates_manage_view') as Record<string, unknown> | null;
expect(notice).toBeTruthy();
expect(manageView).toBeTruthy();
expect(notice?.if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}');
expect(manageView?.if).toBe('{{templates_readiness?.data?.ready}}');
});
});
@@ -0,0 +1,373 @@
// e2e:allow 관리 화면 mergeQuery 왕복·SMS 모달 저장 왕복 브라우저 흐름은
// tests/Playwright/specs/admin/template-lifecycle.spec.ts(bizppurio_manage_round_trip_e2e)가 담당.
/**
* 플러그인 설정 — 알림 템플릿 관리 탭(DB 목록) 구조 검증 (#597 §4.3)
*
* @effects manage_screen_lists_db_rows_with_merge_query_round_trip
*
* templates 탭은 DB 목록 관리 화면이다:
* - 데이터소스 bizppurio_templates_list(GET /admin/templates) — 필터·검색·페이지는
* URL 쿼리 SSoT(bz_status/bz_search/bz_page, mergeQuery 왕복 규약)
* - templates_manage_view — readiness 충족 시에만, 미충족이면 readiness 안내가 담당(배타)
* - 행 액션은 알림 설정 탭 행 하단과 동일 엔드포인트(request/cancel-request/cancel-approval/
* release/sync/delivery) + 관리 전용 삭제(modal_bizppurio_delete, DELETE)
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import footer from '../../../extensions/notification_row_footer.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, findAllByName, evalBinding, type AnyNode } from './helpers';
const root = layout as unknown as AnyNode;
const manageView = findById(root, 'templates_manage_view') as AnyNode;
const modalRoot = { children: (layout as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
describe('manage — 목록 데이터소스(URL 쿼리 SSoT)', () => {
const ds = ((root as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurio_templates_list');
it('bizppurio_templates_list 가 GET /admin/templates 를 조회한다(auto_fetch:false)', () => {
expect(ds).toBeTruthy();
expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates');
expect(ds?.method).toBe('GET');
expect(ds?.auto_fetch).toBe(false);
});
it('params 가 query.bz_status/bz_search/bz_page 바인딩이다(목록 상태의 SSoT 는 URL)', () => {
const params = ds?.params as Record<string, unknown>;
expect(params.status).toBe("{{query.bz_status ?? ''}}");
expect(params.search).toBe("{{query.bz_search ?? ''}}");
expect(params.page).toBe('{{query.bz_page ?? 1}}');
expect(params.per_page).toBe(20);
});
it('templates 탭 진입 시 init_actions 가 목록을 조회한다(새로고침 복원)', () => {
const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? [];
const refetch = inits.find(
(a) => a.handler === 'refetchDataSource'
&& (a.params as { dataSourceId?: string })?.dataSourceId === 'bizppurio_templates_list',
);
expect(refetch).toBeTruthy();
expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
it('tab_templates 탭 버튼이 navigate(mergeQuery) 후 목록을 refetch 한다', () => {
const raw = JSON.stringify(findById(root, 'tab_templates'));
expect(raw).toContain('"handler":"navigate"');
expect(raw).toContain('"tab":"templates"');
expect(raw).toContain('"mergeQuery":true');
expect(raw).toContain('bizppurio_templates_list');
});
});
describe('manage — readiness 게이트(배타 전환)', () => {
it('관리 뷰는 readiness 충족 시에만 노출된다', () => {
expect(manageView).toBeTruthy();
expect(manageView.if).toBe('{{templates_readiness?.data?.ready}}');
});
it('readiness 미충족 안내는 !ready 조건으로 관리 뷰와 상호배타다', () => {
const notice = findById(root, 'templates_readiness');
expect(notice).toBeTruthy();
expect((notice as { if?: string }).if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}');
});
});
describe('manage — 필터·검색·페이지 (mergeQuery 왕복 규약)', () => {
it('상태 필터 Select: value=query.bz_status, 옵션은 template.status.* 어휘, 변경 시 bz_page 리셋 + refetch', () => {
const select = findAllByName(manageView, 'Select')
.find((s) => (s.props as { value?: string })?.value === "{{query.bz_status ?? ''}}");
expect(select).toBeTruthy();
const options = String((select?.props as { options?: string })?.options ?? '');
expect(options).toContain("template.status.' + s");
expect(options).toContain("'draft','requested','approved','rejected','stopped','blocked','dormant'");
const raw = JSON.stringify(select);
expect(raw).toContain('"mergeQuery":true');
expect(raw).toContain('"bz_status":"{{$event.target.value}}"');
expect(raw).toContain('"bz_page":""');
expect(raw).toContain('bizppurio_templates_list');
});
it('검색: 입력은 로컬 초안(bzSearchDraft), 실행 버튼이 bz_search 반영 + bz_page 리셋 + refetch', () => {
const input = findAllByName(manageView, 'Input')
.find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft'));
expect(input).toBeTruthy();
const searchBtn = findAllByName(manageView, 'Button')
.find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search');
const raw = JSON.stringify(searchBtn);
expect(raw).toContain('"mergeQuery":true');
expect(raw).toContain('"bz_search":"{{_local.bzSearchDraft ?? \'\'}}"');
expect(raw).toContain('"bz_page":""');
expect(raw).toContain('bizppurio_templates_list');
});
it('새로고침 버튼은 URL 을 건드리지 않고 목록만 refetch 한다(보던 목록 유지)', () => {
const refreshBtn = findAllByName(manageView, 'Button')
.find((b) => b.text === '$t:sirsoft-message_bizppurio.templates.list.refresh');
expect(refreshBtn).toBeTruthy();
const raw = JSON.stringify(refreshBtn);
expect(raw).toContain('bizppurio_templates_list');
expect(raw).not.toContain('"handler":"navigate"');
});
it('Pagination 이 last_page 를 사용하고 페이지 이동은 bz_page 를 mergeQuery 로 나른 뒤 refetch 한다', () => {
const pagination = findAllByName(manageView, 'Pagination')[0];
expect(pagination).toBeTruthy();
const props = pagination.props as { currentPage?: string; totalPages?: string; hasMorePages?: string };
expect(props.currentPage).toBe('{{bizppurio_templates_list?.data?.pagination?.current_page ?? 1}}');
// last_page 는 대용량 목록 계약상 미상(null)일 수 있어 1 로 채우지 않는다 (pagination.md)
expect(props.totalPages).toBe('{{bizppurio_templates_list?.data?.pagination?.last_page ?? null}}');
expect(props.hasMorePages).toBe('{{bizppurio_templates_list?.data?.pagination?.has_more_pages ?? false}}');
const raw = JSON.stringify(pagination);
expect(raw).toContain('"mergeQuery":true');
expect(raw).toContain('"bz_page":"{{$args[0]}}"');
expect(raw).toContain('bizppurio_templates_list');
});
it('총 건수와 빈 목록 안내가 존재한다', () => {
const raw = JSON.stringify(manageView);
expect(raw).toContain('manage.total_count');
expect(raw).toContain('bizppurio_templates_list?.data?.pagination?.total');
expect(raw).toContain('manage.empty');
});
});
/**
* @effects manage_row_actions_match_row_footer_visibility
*/
describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포인트)', () => {
/** 노드 JSON 에서 템플릿 admin API 경로만 추출한다({{...}} 표현식 부분은 정규화). */
const collectEndpoints = (json: unknown): Set<string> => {
const text = JSON.stringify(json);
const matches = text.match(/\/api\/plugins\/sirsoft-message_bizppurio\/admin\/templates[^"]*/g) ?? [];
return new Set(matches.map((m) => m.replace(/\{\{[^}]+\}\}/g, '{id}')));
};
it('상태 전이 4종(request/cancel-request/release/sync)이 목록 갱신과 함께 배선된다', () => {
const raw = JSON.stringify(manageView);
for (const suffix of ['/request', '/cancel-request', '/release', '/sync']) {
expect(raw, `${suffix} 누락`).toContain(
`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}${suffix}`,
);
}
// 전이 후에는 map 이 아니라 이 화면의 목록을 갱신한다
expect(raw).toContain('bizppurio_templates_list');
});
/**
* 관리 화면(bzRow)과 행 하단(요약 맵)의 노출 조건을 같은 상태 집합에 **실제로 태워** 대조한다.
*
* 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 실제로
* `btn_refresh` 는 footer 가 `(row) && row?.template_code`, manage 는 `bzRow.template_code`
* 로 서로 다른 모양이고, `=== true` 와 truthy 도 갈린다. 두 식을 각자의 컨텍스트로 평가해
* 상태별 결과가 일치하는지를 본다.
*/
const MANAGE_ROWS: Record<string, Record<string, unknown>> = {
'행만 있고 내용 없음': { has_content: false, status: 'draft' },
draft: { has_content: true, status: 'draft', template_code: null },
requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' },
approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' },
rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] },
'반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] },
dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' },
stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' },
blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' },
};
/** manage 화면 식을 bzRow 컨텍스트로 평가 */
const evalManage = (expr: string, bzRow: Record<string, unknown>): boolean => {
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
return Boolean(new Function('bzRow', `return (${body});`)(bzRow));
};
/** footer 식을 요약 맵 컨텍스트로 평가 */
const evalFooter = (expr: string, bzRow: Record<string, unknown>): boolean => {
const body = expr.trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
// eslint-disable-next-line no-new-func
const fn = new Function('bizppurioTemplates', 'extensionPointProps', `return (${body});`);
return Boolean(fn({ data: { templates: { welcome: bzRow } } }, { definition: { type: 'welcome' } }));
};
const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
const footerRow = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode;
const byTextKey = (root: AnyNode, key: string) => findAllByName(root, 'Button')
.find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`);
/** 관리 화면 ↔ 행 하단에서 같은 의미를 갖는 버튼 쌍 */
const PAIRS = [
'template.row.btn_request',
'template.row.btn_cancel_request',
'template.row.btn_release',
'template.row.btn_refresh',
'template.row.btn_edit_approved',
];
it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 행 하단에서 상태별로 같은 결과를 낸다', (key) => {
const m = byTextKey(manageView, key);
const f = byTextKey(footerRow, key);
expect(m?.if, `관리 화면에 ${key} if 가 없다`).toBeTruthy();
expect(f?.if, `행 하단에 ${key} if 가 없다`).toBeTruthy();
for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
expect(evalManage(m!.if as string, bzRow), `${key} @ ${state}`)
.toBe(evalFooter(f!.if as string, bzRow));
}
});
it('작성/수정 버튼은 행 하단의 작성+수정 두 버튼을 합친 것과 같은 상태에서 노출된다', () => {
// 관리 화면은 DB 목록이라 "행 없음" 이 없다 — footer 의 btn_compose(!row || !has_content)와
// btn_edit(has_content && draft|rejected)를 합집합으로 놓고 대조한다.
const m = findAllByName(manageView, 'Button')
.find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content"));
expect(m?.if, '관리 화면 작성/수정 버튼을 찾지 못했다').toBeTruthy();
const compose = byTextKey(footerRow, 'template.row.btn_compose');
const edit = byTextKey(footerRow, 'template.row.btn_edit');
expect(compose?.if).toBeTruthy();
expect(edit?.if).toBeTruthy();
for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
const footerVisible = evalFooter(compose!.if as string, bzRow) || evalFooter(edit!.if as string, bzRow);
expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(footerVisible);
}
});
it('작성/수정 버튼의 라벨이 내용 유무로 갈린다(미작성 행에 "수정" 이 뜨지 않는다)', () => {
const m = findAllByName(manageView, 'Button')
.find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content"));
const text = m?.text as string;
expect(text).toContain('bzRow.has_content');
expect(text).toContain('btn_edit');
expect(text).toContain('btn_compose');
expect(ko.manage.btn_compose).toBeTruthy();
expect(en.manage.btn_compose).toBeTruthy();
});
it('승인 취소·SMS 본문·delivery upsert 는 행 하단(footer)과 같은 엔드포인트 계열을 쓴다', () => {
const layoutEndpoints = collectEndpoints(layout);
const footerEndpoints = collectEndpoints(footer);
// footer 가 쓰는 계열(map 조회 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다
for (const ep of footerEndpoints) {
if (ep.endsWith('/map')) continue;
expect(layoutEndpoints, `관리 화면에 ${ep} 누락`).toContain(ep);
}
// 승인 취소 모달 공유 확인
expect(JSON.stringify(layout)).toContain(
'/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval',
);
expect(JSON.stringify(layout)).toContain(
'/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}',
);
});
it('모달 5종(작성/SMS/승인취소/반려/삭제)이 이 레이아웃에 등록된다', () => {
const ids = ((layout as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
expect(ids).toEqual([
'modal_bizppurio_template',
'modal_bizppurio_sms',
'modal_bizppurio_cancel_approval',
'modal_bizppurio_rejection',
'modal_bizppurio_delete',
]);
});
});
describe('manage — 삭제 모달(modal_bizppurio_delete)', () => {
const modal = findById(modalRoot, 'modal_bizppurio_delete');
it('행 삭제 버튼이 bz_delete_modal seed 후 삭제 확인 모달을 연다', () => {
const deleteBtn = findAllByName(manageView, 'Button')
.find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete');
expect(deleteBtn).toBeTruthy();
const raw = JSON.stringify(deleteBtn);
expect(raw).toContain('"bz_delete_modal"');
expect(raw).toContain('modal_bizppurio_delete');
});
it('확정 시 DELETE /admin/templates/{id} 후 목록 갱신 + 모달 닫힘', () => {
expect(modal).toBeTruthy();
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_delete_modal?.id}}');
expect(raw).toContain('"method":"DELETE"');
expect(raw).toContain('bizppurio_templates_list');
expect(raw).toContain('"closeModal"');
});
it('카카오측 동반 삭제 여부(kakao_deleted)에 따라 완료 문구를 분기한다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('kakao_deleted');
expect(raw).toContain('manage.delete.done_with_kakao');
expect(raw).toContain('manage.delete.done_db_only');
});
});
describe('manage — i18n 정합(manage.* 키 가족)', () => {
it("동적 접두 'manage.owner.' 가족: core/module/plugin 라벨이 ko·en 에 존재한다", () => {
for (const dict of [ko, en] as Array<Record<string, any>>) {
for (const owner of ['core', 'module', 'plugin']) {
expect(dict.manage.owner[owner], `manage.owner.${owner}`).toBeTruthy();
}
}
});
it('목록 컬럼 헤더 키 7종이 ko·en 에 존재한다', () => {
const columns = ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions'];
for (const dict of [ko, en] as Array<Record<string, any>>) {
for (const col of columns) {
expect(dict.manage.columns[col], `manage.columns.${col}`).toBeTruthy();
}
}
});
});
/**
* @effects upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab
*/
describe('manage 모달 — 이미지 업로드 배선·SMS 언어 탭 (#597 §15.2 U1 / §15.1)', () => {
/**
* 관리 화면(plugin_settings.json)은 3면 오버레이(notification_tab_*.json)의 패리티 대상이
* **아니다** — 세 오버레이는 서로 전문 동일성으로 묶여 있지만 이 면은 별도 파일이다.
* 그래서 §15.2 U1(업로드 중 저장 잠금)·§15.1(SMS 언어 탭)이 이 면에서 되돌려져도
* 다른 어떤 테스트도 red 가 되지 않는다. 여기서 직접 고정한다.
*/
const modalRaw = JSON.stringify(modalRoot);
it('작성 모달의 저장 계열 4버튼이 업로드 중 잠기고, 취소는 잠기지 않는다', () => {
const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode;
expect(composeModal, '관리 화면에 작성 모달이 없다').toBeTruthy();
const buttons = findAllByName(composeModal, 'Button');
const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/
.test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', '')));
expect(saveButtons.length, '신규/수정 × 저장/저장후신청 = 4').toBe(4);
for (const b of saveButtons) {
const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
'업로드 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true);
expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }),
'업로드가 끝났는데 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false);
}
const cancel = buttons.find((b) => b.text === '$t:common.cancel');
const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? '');
expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
'취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false);
});
it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => {
expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''");
});
it('SMS 모달이 로케일 탭을 제공하고 본문을 로케일 맵으로 읽고 쓴다', () => {
const tabs = findById(modalRoot, 'bz_sms_lang_tabs');
expect(tabs, '관리 화면 SMS 모달에 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy();
expect(JSON.stringify(tabs)).toContain('{{$locales}}');
expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]');
expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"');
expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)");
});
});
@@ -0,0 +1,201 @@
/**
* @file template_request_reentrancy_guard.test.ts
* @description 검수 신청 체인 재진입(더블 클릭) 가드 회귀 테스트 (#597 §6.3 10c)
*
* 배경: 검수 신청(POST …/request)은 kapi add/update 를 동반하므로 더블 클릭이
* 두 체인을 발화하면 카카오측 중복 등록·중복 채번이 발생할 수 있다. disabled
* prop 은 React 리렌더 이후에만 반영되어 같은 태스크에서 연속 디스패치되는
* 두 번째 click 을 막지 못한다 — 실측: 더블 클릭 → PUT 2회 + request 2회.
*
* 방어는 click 액션(sequence) 레벨의 `if` 재진입 가드다(코어 선례:
* admin_role_form.json 등의 `"if": "{{!_global.isSaving}}"`). 엔진 if 평가는
* 디스패치 시점의 동기 검사라 리렌더를 기다리지 않는다.
*
* 이 테스트는 신청 체인을 포함하는 모든 click 액션이 재진입 가드(if)를
* 보유하는지 4개 산출 파일 전수로 고정한다. 가드가 한 파일에서라도 빠지면
* 그 면에서만 중복 신청이 재발한다.
*
* 실행 검증은 §6.3 10c 브라우저 실측이 담당한다. 라운드 5 실측 결과를 여기 남긴다:
* **클라이언트 if 가드만으로는 요청 2회가 나간다.** setState 가 전역 스토어에 반영되기
* 전에 두 번째 click 이 디스패치되기 때문이며, 30ms 간격 더블 클릭에서 재현된다.
*
* 중복 신청을 실제로 막는 것은 **서버의 원자 선점**(claimForInspection)이다 — 실측에서
* 두 번째 POST …/request 는 422 "현재 상태(requested)에서는 검수를 신청할 수 없습니다."
* 로 거부됐고, 카카오측 중복 등록은 발생하지 않았다. 저장(PUT)은 멱등이라 무해하다.
*
* 따라서 이 구조 테스트는 "가드가 선언돼 있다" 만 고정한다 — 효력의 SSoT 는 서버 가드이고,
* 그것은 PHPUnit(claimForInspection)과 §6.3 10c 실측이 고정한다.
*
* @effects request_chain_click_actions_carry_reentrancy_guard, save_chain_click_actions_carry_reentrancy_guard
*/
import fs from 'fs';
import path from 'path';
import { describe, it, expect } from 'vitest';
const BASE = path.resolve(__dirname, '../../../..');
const FILES = [
'resources/extensions/notification_tab_core.json',
'resources/extensions/notification_tab_board.json',
'resources/extensions/notification_tab_ecommerce.json',
'resources/extensions/notification_row_footer.json',
'resources/layouts/admin/plugin_settings.json',
];
interface ClickAction {
file: string;
targets: string[];
ifExpr: string | undefined;
raw: string;
buttonDisabled: string | undefined;
}
/**
* 검수 신청(…/request) apiCall 을 포함하는 click 액션을 전수 수집합니다.
* cancel-request 는 서버 상태 가드(422)로 순차 중복이 무해하므로 제외한다.
*
* @param file 저장소 루트 기준 상대 경로
* @returns 신청 체인을 품은 click 액션 목록
*/
function collectRequestClickActions(file: string): ClickAction[] {
const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8'));
const found: ClickAction[] = [];
const collectTargets = (node: unknown, acc: string[]): void => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach((c) => collectTargets(c, acc));
return;
}
const n = node as Record<string, any>;
if (
n.handler === 'apiCall' &&
typeof n.target === 'string' &&
/\/request$/.test(n.target)
) {
acc.push(n.target);
}
Object.values(n).forEach((v) => collectTargets(v, acc));
};
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
const n = node as Record<string, any>;
if (Array.isArray(n.actions)) {
for (const action of n.actions) {
if (action?.type !== 'click') continue;
const targets: string[] = [];
collectTargets(action, targets);
if (targets.length > 0) {
found.push({
file,
targets,
ifExpr: action.if,
raw: JSON.stringify(action),
buttonDisabled: n.name === 'Button' ? n.props?.disabled : undefined,
});
}
}
}
Object.values(n).forEach(walk);
};
walk(doc);
return found;
}
describe('검수 신청 체인 재진입 가드 (#597 10c)', () => {
const all = FILES.flatMap(collectRequestClickActions);
it('신청 체인을 포함한 click 액션이 산출 파일 전체에서 수집된다 (0건이면 아래 단언은 공회전한다)', () => {
// 모달 저장 후 신청(신규/수정) ×4파일 + row footer 신청 + 관리 화면 행 신청
expect(all.length).toBeGreaterThanOrEqual(6);
});
it.each(FILES)('%s 의 신청 체인 click 액션은 모두 재진입 if 가드를 갖는다', (file) => {
const actions = all.filter((a) => a.file === file);
for (const action of actions) {
expect(
action.ifExpr,
`${file} 의 신청 체인(${action.targets[0]})에 if 재진입 가드가 없다 — 더블 클릭이 중복 신청을 발화한다`,
).toBeTruthy();
expect(
action.ifExpr,
`${file} 의 신청 체인 if 가드는 in-flight 플래그(isSaving/bz_row_busy)를 부정 조건으로 검사해야 한다`,
).toMatch(/!\((_global\.bz_tpl_modal\?\.isSaving|_global\.bz_row_busy) \?\? false\)/);
}
});
it('신청 체인 버튼은 in-flight 플래그를 disabled 로 바인딩한다 (구독 없으면 액션 컨텍스트가 갱신되지 않아 if 가드가 항상 stale false 를 본다 — 실측)', () => {
for (const action of all) {
const flag = action.ifExpr?.includes('bz_row_busy') ? 'bz_row_busy' : 'bz_tpl_modal?.isSaving';
expect(
action.buttonDisabled,
`${action.file} 의 신청 체인 버튼(${action.targets[0]})은 disabled 를 ${flag} 로 바인딩해야 한다 — 바인딩이 없으면 리렌더·구독이 일어나지 않아 더블 클릭이 그대로 통과한다`,
).toContain(flag);
}
});
it('bz_row_busy 플래그를 쓰는 체인은 성공·실패 양쪽에서 플래그를 해제한다', () => {
const rowChains = all.filter((a) => a.ifExpr?.includes('bz_row_busy'));
for (const action of rowChains) {
const resets = action.raw.match(/"bz_row_busy": ?false/g) ?? [];
expect(
resets.length,
`${action.file} 의 row 신청 체인은 onSuccess·onError 양쪽에서 bz_row_busy 를 해제해야 한다 — 한쪽만 해제하면 실패 후 버튼이 영구 잠긴다`,
).toBeGreaterThanOrEqual(2);
}
});
});
describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파생)', () => {
/**
* 브라우저 실측에서 [저장] 더블 클릭 시 PUT 이 **2회** 나갔다.
*
* 형제 버튼인 [저장 후 검수 신청] 은 이미 액션 레벨 if 로 재진입을 막고 있었는데
* 저장만 빠져 있었다 — 같은 모달의 같은 결함군에 규칙이 두 벌이었던 셈이다.
* disabled prop 은 리렌더 이후에만 반영되므로 같은 태스크의 두 번째 click 을 막지 못한다.
*/
const GUARD = '{{!(_global.bz_tpl_modal?.isSaving ?? false)}}';
/** 저장 버튼(btn_save)의 click 액션을 전수 수집한다. */
const collectSaveClickActions = (file: string): Array<{ file: string; ifExpr?: string }> => {
const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8'));
const found: Array<{ file: string; ifExpr?: string }> = [];
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { node.forEach(walk); return; }
const n = node as Record<string, any>;
const isSave = n.name === 'Button'
&& typeof n.text === 'string'
&& n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save');
if (isSave && Array.isArray(n.actions)) {
for (const a of n.actions) {
if (a?.type === 'click') found.push({ file, ifExpr: a.if });
}
}
Object.values(n).forEach(walk);
};
walk(doc);
return found;
};
it('저장 버튼의 click 액션이 4개 산출 파일 전수에서 재진입 가드를 보유한다', () => {
const all = FILES.flatMap(collectSaveClickActions);
// 3면 오버레이 + 관리 화면 = 4면 × (신규/수정) 2버튼
expect(all.length, '저장 버튼 click 액션 수').toBe(8);
for (const a of all) {
expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`)
.toBe(GUARD);
}
});
});
@@ -2,10 +2,16 @@
* sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.
*
* 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인
* 식별자가 네임스페이스로 접두된다.
* 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable
*
* 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거).
* 커스텀 핸들러 추가 시 이 맵에 등록한다.
* - insertVariable: 작성 모달·SMS 모달 본문에 `#{변수}` 커서 삽입 (#597 §4.2)
* - uploadTemplateImage: 이미지형 템플릿 이미지 업로드 프록시 호출 (multipart — apiCall 미적합)
*/
export const handlerMap = {} as const;
import { insertVariableHandler } from './insertVariable';
import { uploadTemplateImageHandler } from './uploadTemplateImage';
export const handlerMap = {
insertVariable: insertVariableHandler,
uploadTemplateImage: uploadTemplateImageHandler,
} as const;
@@ -0,0 +1,117 @@
/**
* insertVariable 핸들러 (#597 §4.2)
*
* 알림톡 템플릿 작성 모달·SMS 본문 모달에서 "사용 가능 변수" 칩을 클릭하면, 대상 본문의
* 현재 커서 위치에 `#{변수}` 를 삽입한다. 값 자체는 G7 상태를 SSoT 로 삼아 갱신한다
* (DOM 값을 직접 쓰지 않으므로 다음 렌더에서 상태값으로 덮여 사라지는 충돌이 없다).
*
* 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.
*
* 레이아웃 JSON 사용 예:
* {
* "handler": "sirsoft-message_bizppurio.insertVariable",
* "params": {
* "variable": "name",
* "target": "bz_template_content",
* "stateTarget": "global",
* "statePath": "bz_tpl_modal.content.templateContent"
* }
* }
*/
import type { ActionContext, ActionWithParams } from '../types';
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {
log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),
error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),
};
/** 삽입 대상 본문 필드(name) 기본값 */
const DEFAULT_TARGET = 'bz_template_content';
/** 상태 경로 기본값 */
const DEFAULT_STATE_PATH = 'bz_tpl_modal.content.templateContent';
/**
* 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.
*
* @param action 액션 객체(params.variable/target/stateTarget/statePath)
* @param context 액션 컨텍스트(state 스냅샷)
*/
export function insertVariableHandler(
action: ActionWithParams,
context: ActionContext,
): void {
const G7Core = (window as any).G7Core;
const params = action.params ?? {};
const variable = String(params.variable ?? '').trim();
const target = String(params.target ?? DEFAULT_TARGET);
const stateTarget = String(params.stateTarget ?? 'global');
const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);
if (variable === '') {
logger.warn('삽입할 변수명이 지정되지 않았습니다.');
return;
}
const setState = stateTarget === 'local'
? G7Core?.state?.setLocal
: (G7Core?.state?.setGlobal ?? G7Core?.state?.set);
const getState = stateTarget === 'local'
? G7Core?.state?.getLocal
: (G7Core?.state?.getGlobal ?? G7Core?.state?.get);
if (!setState || !getState) {
logger.error('G7Core.state API 를 사용할 수 없습니다.');
return;
}
// 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getState.
const stateSnapshot = stateTarget === 'local'
? ((context as any)?.state ?? getState() ?? {})
: (getState() ?? {});
const current = String(readPath(stateSnapshot, statePath) ?? '');
const token = `#{${variable}}`;
// 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.
const field = document.querySelector<HTMLTextAreaElement | HTMLInputElement>(
`textarea[name="${target}"], input[name="${target}"]`,
);
const domValue = field?.value ?? '';
// DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
const inSync = domValue === current;
const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
const next = current.slice(0, start) + token + current.slice(end);
setState({ [statePath]: next });
// 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).
if (field) {
const caret = start + token.length;
requestAnimationFrame(() => {
try {
field.focus();
field.setSelectionRange(caret, caret);
} catch {
// setSelectionRange 미지원 input — 무시
}
});
}
logger.log(`변수 삽입: ${token} → ${stateTarget}:${statePath}`);
}
/**
* dot notation 경로로 객체에서 값을 읽습니다. (예: "bz_tpl_modal.content.templateContent")
*
* @param obj 대상 객체
* @param path dot 경로
* @return 경로 값(없으면 undefined)
*/
function readPath(obj: Record<string, any>, path: string): unknown {
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
}
@@ -0,0 +1,292 @@
/**
* uploadTemplateImage 핸들러 (#597 §3.2 · 부록 A-7)
*
* 이미지형 알림톡 템플릿 작성 모달에서 파일 선택(change) 시, 선택한 이미지를 플러그인의
* 이미지 업로드 프록시(`POST /admin/templates/image` — kapi 위임)로 전송하고, 반환된
* 카카오 이미지 URL 을 params.statePathUrl 에, 파일명을 params.statePathName 에 설정한다.
*
* 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.
* 진행 상태·오류는 params.statePathStatus(기본 bz_tpl_upload) 하위의
* `{uploading, error}` 로 노출한다.
*
* 레이아웃 JSON 사용 예:
* {
* "type": "change",
* "handler": "sirsoft-message_bizppurio.uploadTemplateImage",
* "params": {
* "stateTarget": "global",
* "statePathUrl": "bz_tpl_modal.content.templateImageUrl",
* "statePathName": "bz_tpl_modal.content.templateImageName",
* "statePathStatus": "bz_tpl_upload"
* }
* }
*/
import type { ActionContext, ActionWithParams } from '../types';
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {
log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),
error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),
};
const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/templates/image';
/** 서버 사유가 없을 때 쓰는 최후 폴백 문구의 다국어 키 */
const FAILURE_KEY = 'sirsoft-message_bizppurio.template.form.image_upload_failed';
/**
* 운영자 로케일을 Accept-Language 로 실어 보냅니다.
*
* 이 핸들러는 multipart 때문에 코어 ApiClient/ActionDispatcher 를 우회해 raw fetch 를
* 쓰므로, 두 경로가 공통으로 붙이는 로케일 헤더를 여기서 직접 재현한다. 빠뜨리면 서버
* 검증 메시지와 카카오 사유 원문이 운영자 언어가 아닌 앱 기본 로케일로 돌아온다.
*
* @returns Accept-Language 헤더 객체 (미설정 시 빈 객체)
*/
function localeHeader(): Record<string, string> {
if (typeof window === 'undefined') {
return {};
}
const locale = localStorage.getItem('g7_locale');
return locale ? { 'Accept-Language': locale } : {};
}
/**
* 업로드 실패 폴백 문구를 반환합니다.
*
* @param G7Core 전역 G7Core 객체
* @returns 번역된 문구 (해석 실패 시 영문 폴백)
*/
function failureText(G7Core: any): string {
const translated = G7Core?.t?.(FAILURE_KEY);
return (typeof translated === 'string' && translated !== FAILURE_KEY)
? translated
: 'Failed to upload the image.';
}
/**
* stateTarget(global|local)에 맞는 상태 setter 를 반환합니다.
*
* @param G7Core 전역 G7Core 객체
* @param target 'global' | 'local'
* @returns 상태 setter 또는 null
*/
function resolveSetter(G7Core: any, target: string): ((updates: Record<string, unknown>) => void) | null {
if (target === 'local') {
return G7Core?.state?.setLocal ?? null;
}
return G7Core?.state?.setGlobal ?? G7Core?.state?.set ?? null;
}
/**
* stateTarget(global|local)에 맞는 상태 getter 를 반환합니다.
*
* @param G7Core 전역 G7Core 객체
* @param target 'global' | 'local'
* @returns 상태 getter 또는 null
*/
function resolveGetter(G7Core: any, target: string): (() => Record<string, any>) | null {
if (target === 'local') {
return G7Core?.state?.getLocal ?? null;
}
return G7Core?.state?.getGlobal ?? G7Core?.state?.get ?? null;
}
/**
* dot notation 경로로 객체에서 값을 읽습니다.
*
* @param obj 대상 객체
* @param path dot 경로
* @returns 경로 값(없으면 undefined)
*/
function readPath(obj: Record<string, any>, path: string): unknown {
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
}
/**
* 이 업로드의 결과를 아직 써도 되는지 판정합니다.
*
* 응답이 도착했을 때 모달이 이미 다른 알림으로 바뀌었거나 다시 열렸다면, 그 결과는 지금
* 화면이 편집 중인 템플릿의 것이 아니다. 그대로 쓰면 A 알림의 이미지가 B 알림 폼에 기입되고
* (실패 경로에서는 B 가 방금 시딩한 값이 지워진다) 운영자에게는 아무 단서도 남지 않는다.
*
* 판정 신호는 진행 플래그 자신이다 — 모달을 여는 지점은 모두 statePathStatus 를
* `{uploading:false, error:null}` 로 리시드하므로, 우리가 켜 둔 uploading 이 사라졌다면
* 그 사이에 모달이 다시 시딩된 것이다.
*
* @param G7Core 전역 G7Core 객체
* @param stateTarget 'global' | 'local'
* @param pathStatus 업로드 상태 경로
* @returns 결과를 반영해도 되면 true
*/
function stillOwnsUpload(G7Core: any, stateTarget: string, pathStatus: string): boolean {
const getter = resolveGetter(G7Core, stateTarget);
// getter 를 못 쓰면 판정 불가 — 기존 동작(그대로 반영)을 유지한다.
if (!getter) {
return true;
}
try {
return readPath(getter() ?? {}, `${pathStatus}.uploading`) === true;
} catch {
return true;
}
}
/** 동시 업로드 차단 플래그 — 진행 중에는 새 파일 선택을 받지 않는다. */
let uploadInFlight = false;
/**
* 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.
*
* @param action 액션 객체(params.stateTarget/statePathUrl/statePathName/statePathStatus)
* @param context 액션 컨텍스트($event 로 파일 접근)
*/
export async function uploadTemplateImageHandler(
action: ActionWithParams,
context: ActionContext,
): Promise<void> {
const G7Core = (window as any).G7Core;
const params = action.params ?? {};
const stateTarget = String(params.stateTarget ?? 'global');
const pathUrl = String(params.statePathUrl ?? 'bz_tpl_modal.content.templateImageUrl');
const pathName = String(params.statePathName ?? 'bz_tpl_modal.content.templateImageName');
const pathStatus = String(params.statePathStatus ?? 'bz_tpl_upload');
const setState = resolveSetter(G7Core, stateTarget);
if (!setState) {
logger.error('G7Core.state setter 를 사용할 수 없습니다.');
return;
}
const event = (context?.event ?? (action as any)?.event) as Event | undefined;
const input = event?.target as HTMLInputElement | undefined;
const file = input?.files?.[0];
if (!file) {
return;
}
// 진행 중인 업로드가 있으면 새 선택을 받지 않는다. 두 요청이 겹치면 먼저 끝난 쪽이
// uploading:false 를 써서 저장 버튼 잠금이 풀리고, 나중에 끝나는 업로드가 저장 이후에
// templateImageUrl 을 덮는다 — 잠금이 막으려던 "빈/엉뚱한 URL 저장" 이 그대로 재현된다.
if (uploadInFlight) {
logger.warn('이미 업로드가 진행 중입니다 — 새 파일 선택을 무시합니다.');
if (input) {
input.value = '';
}
return;
}
uploadInFlight = true;
setState({ [`${pathStatus}.uploading`]: true, [`${pathStatus}.error`]: null });
try {
const token = localStorage.getItem('auth_token') ?? '';
const form = new FormData();
form.append('image', file, file.name);
const res = await fetch(UPLOAD_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...localeHeader(),
},
body: form,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json?.success === false) {
// 서버가 내려준 사유(카카오 원문·검증 메시지)는 그대로 보여준다 —
// Accept-Language 를 실어 보냈으므로 운영자 로케일로 도착한다.
const message = json?.errors?.bizppurio_message
|| json?.errors?.image?.[0]
|| json?.message
|| failureText(G7Core);
if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
failUpload(setState, pathStatus, pathUrl, pathName, message);
}
logger.warn('이미지 업로드 실패:', message);
return;
}
const url = String(json?.data?.url ?? '').trim();
// 성공 응답인데 url 이 비어 있으면 성공이 아니다 — 그대로 두면 배너는 성공으로 보이는데
// templateImageUrl 이 빈 채로 저장돼 검수 신청 시점에야 드러난다.
if (url === '') {
if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core));
}
logger.warn('이미지 업로드 응답에 url 이 없습니다:', json);
return;
}
if (!stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
logger.warn('모달이 다시 시딩되어 업로드 결과를 버립니다:', url);
return;
}
setState({
[`${pathStatus}.uploading`]: false,
[`${pathStatus}.error`]: null,
[pathUrl]: url,
[pathName]: file.name,
});
logger.log('이미지 업로드 완료:', url);
} catch (e) {
// 예외 원문(TypeError 등)은 운영자에게 의미가 없고 내부 구현을 노출한다 —
// 화면에는 번역 문구만 싣고 원문은 콘솔로만 남긴다.
if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core));
}
logger.error('이미지 업로드 예외:', e);
} finally {
uploadInFlight = false;
// 같은 파일 재선택이 가능하도록 input 값 초기화
if (input) {
input.value = '';
}
}
}
/**
* 업로드 실패 상태를 기록하고 직전 이미지 값을 무효화합니다.
*
* 실패 경로가 url/파일명을 그대로 두면, 배너는 "실패" 인데 폼에는 직전에 성공한 이미지가
* 남아 그대로 저장된다 — 운영자는 방금 고른 이미지가 저장된다고 믿는다. 실패했으면
* 화면에 보이는 이미지도 없어야 한다.
*
* @param setState 상태 setter
* @param pathStatus 업로드 상태 경로 (uploading/error)
* @param pathUrl 이미지 URL 상태 경로
* @param pathName 이미지 파일명 상태 경로
* @param message 화면에 표시할 실패 사유
*/
function failUpload(
setState: (patch: Record<string, unknown>) => void,
pathStatus: string,
pathUrl: string,
pathName: string,
message: string,
): void {
setState({
[`${pathStatus}.uploading`]: false,
[`${pathStatus}.error`]: message,
[pathUrl]: '',
[pathName]: '',
});
}
@@ -3,7 +3,7 @@
*
* 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에
* 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.
* (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.)
* (현재 등록 핸들러: insertVariable, uploadTemplateImage — handlers/index.ts 참조.)
*/
import { handlerMap } from './handlers';
@@ -1,27 +1,27 @@
{
"name": "Bizppurio Messaging",
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.",
"description": "Bizppurio SMS/LMS and Kakao Alimtalk delivery plugin.",
"settings": {
"title": "Bizppurio Messaging Settings",
"description": "Manage Bizppurio integration credentials and sending options.",
"description": "Manage Bizppurio integration and delivery settings.",
"save": "Save",
"saving": "Saving...",
"save_success": "Settings saved.",
"save_failed": "Failed to save settings.",
"test_mode": {
"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."
"label": "Test Mode",
"hint": "In test mode, messages are sent to the Bizppurio test domain. Turn it off to send via the production domain.",
"account_notice": "We recommend using separate Bizppurio accounts for testing and production."
},
"live_mode_warning_title": "Sending in production",
"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.",
"live_mode_warning_title": "Sending to production",
"live_mode_warning_body": "Test mode is off — real customers will receive messages and delivery costs will be billed. Keep test mode on during inspection.",
"sections": {
"api": {
"title": "API Integration",
"description": "Credentials shared by the sending system and the Kakao management system."
},
"sending": {
"title": "Sending Options",
"title": "Sending Settings",
"description": "Sender information used for SMS and alimtalk delivery."
}
},
@@ -32,20 +32,20 @@
},
"password": {
"label": "Bizppurio Module Password",
"hint": "Please enter the Bizppurio module password. (Bizppurio console > Module Integration Settings > Change Module Password)"
"hint": "Enter the Bizppurio module password. (Bizppurio console > Module integration settings > Change module password)"
},
"connection_check": {
"label": "Connection Check",
"hint": "Click to instantly verify the saved ID and password. (Only reflects saved values)",
"hint": "Verify that the saved ID and password are valid. (Applies after saving)",
"button": "Check Connection",
"checking": "Checking...",
"success": "Authentication verified successfully. The ID and password are correct.",
"failed": "Failed to verify authentication.",
"success": "Authentication verified. The ID and password are correct.",
"failed": "Authentication check failed.",
"unsaved_changes": "Please save your changes first."
},
"api_key": {
"label": "API Key",
"hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support."
"hint": "Request the API key from Bizppurio customer support along with your account ID."
},
"sender_number": {
"label": "Sender Number",
@@ -53,175 +53,233 @@
},
"sender_key": {
"label": "Alimtalk Sender Profile Key",
"hint": "The 40-character sender profile key used for alimtalk delivery and template lookup."
},
"template_cache_minutes": {
"label": "Alimtalk content cache (minutes)",
"hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).",
"clear_cache": "Clear cache",
"clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.",
"clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.",
"clear_cache_failed": "Failed to clear the cache."
"hint": "The 40-character sender profile key used for alimtalk delivery and template requests."
}
},
"report": {
"section_title": "Report Endpoint",
"hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).",
"note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.",
"section_title": "Report Receiving",
"hint": "Bizppurio pushes delivery results (URL PUSH) to this address after sending. Register the address below with the Bizppurio business team (or the report settings in the console).",
"note": "Once registered, delivery results are recorded automatically. Before registration, only the send request is confirmed and final results are not updated.",
"copy": "Copy",
"copied": "Report endpoint copied to clipboard."
},
"cache": {
"section_title": "Alimtalk dispatch content cache",
"section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume."
"copied": "Report receiving address copied to clipboard."
},
"tabs": {
"connection": "Settings",
"templates": "Alimtalk Templates"
"templates": "Notification Templates"
},
"preparation": {
"intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.",
"intro": "To send SMS and Kakao alimtalk, complete the preparations below in the Bizppurio console first.",
"sms_label": "SMS/LMS",
"sms_sender": "Register your sender number.",
"sms_sender": "Register a sender number.",
"kakao_label": "Kakao Alimtalk",
"kakao_channel": "Create a KakaoTalk business channel and register a sender profile.",
"kakao_template": "Register your Alimtalk templates and get them approved.",
"kakao_apikey": "Request an API key from Bizppurio support.",
"console_link": "Open Bizppurio console"
"kakao_template": "Alimtalk templates are composed and submitted for inspection in the admin notification settings (Bizppurio tab).",
"kakao_apikey": "Request an API key from customer support.",
"console_link": "Open Bizppurio Console"
}
},
"channels": {
"bizppurio_tab": "Bizppurio"
},
"binding": {
"section_title": "KakaoTalk Alimtalk Binding",
"list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.",
"section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.",
"modal_title": "Alimtalk Binding · {name}",
"unbound": "Not connected",
"unavailable": "Unavailable — reconnect needed",
"btn_connect": "Connect",
"btn_change": "Change",
"fallback_on": "SMS fallback ON",
"fallback_off": "SMS fallback OFF",
"connected_template": "Connected Template",
"none": "Not connected",
"no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.",
"templates_load_failed": "Failed to load the template list. Check the plugin settings (credentials).",
"fallback_sms": "Fall back to SMS on failure",
"fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.",
"variables_hint": "Available variables (auto-substituted on send)",
"saved": "Alimtalk binding saved.",
"save_error": "Failed to save alimtalk binding."
"variables_hint": "Available variables (click to insert at the cursor)"
},
"banner": {
"not_ready": "Setup required for delivery is incomplete.",
"not_ready": "Required delivery settings are not complete.",
"setup_action": "Set up",
"test_mode": "Inspection mode — messages are not actually sent."
"test_mode": "Test mode — no real messages are sent."
},
"dispatch_result": {
"column_header": "SMS/Alimtalk Result",
"inspection_label": "Inspection",
"inspection_label": "Test",
"low_balance": "Low balance",
"fallback": "SMS fallback {status}",
"detail_title": "SMS/Alimtalk delivery result",
"detail_title": "SMS/Alimtalk Delivery Result",
"channel_label": "Channel: {channel}",
"sent_content_label": "Actual sent content",
"sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above."
"sent_content_label": "Sent content",
"sent_content_hint": "Alimtalk is sent with the approved template content, which may differ from the body above."
},
"editor": {
"data_source": {
"dispatch_results": "Bizppurio dispatch results"
}
},
"templates": {
"title": "Alimtalk Templates",
"description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.",
"readiness": {
"title": "The following settings are required to use alimtalk templates",
"go_settings": "Go to settings",
"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 inspection mode and using templates approved by Kakao."
},
"list_error": {
"title": "Failed to load the template list"
},
"list_notice": {
"console_desc": "Register, edit, and review templates in the Bizppurio console.",
"console_link": "Open Bizppurio console"
},
"list": {
"refresh": "Refresh",
"search": "Search",
"search_placeholder": "Search by name (2-50 chars)",
"filter_all": "All statuses",
"empty": "No alimtalk templates registered.",
"empty_hint": "Templates registered in the Bizppurio console appear here.",
"load_failed": "Failed to load templates. Check the sender profile key and API key.",
"columns": {
"no": "No.",
"name": "Name",
"code": "Code",
"status": "Status",
"requested_at": "Requested",
"processed_at": "Processed",
"actions": "Content"
}
},
"template": {
"tab_guide": "Compose an alimtalk template per notification and request inspection. Once Kakao approves it, alimtalk is sent automatically. SMS fallback and SMS-only delivery are also configured here.",
"status": {
"sendable": "Sendable",
"inspecting": "Inspecting",
"unwritten": "Not composed",
"draft": "Draft",
"requested": "Under inspection",
"approved": "Approved",
"rejected": "Rejected",
"uninspected": "Not inspected",
"stopped": "Stopped",
"blocked": "Blocked",
"dormant": "Dormant",
"unknown": "Unknown"
"dormant": "Dormant"
},
"status_sub": {
"rdy": "(unused)"
"row": {
"alimtalk_label": "Alimtalk",
"requested_on": "Requested {date}",
"synced_on": "Synced {date}",
"alimtalk_enabled": "Enabled",
"btn_view_reason": "View reason",
"btn_compose": "Compose alimtalk template",
"btn_edit": "Edit",
"btn_edit_approved": "Edit (cancel approval)",
"btn_request": "Request inspection",
"btn_cancel_request": "Cancel request",
"btn_release": "Release dormant",
"btn_refresh": "Refresh",
"requested_toast": "Inspection requested.",
"cancel_request_toast": "Inspection request cancelled.",
"released_toast": "Dormant state released.",
"synced_toast": "Kakao inspection status synchronized.",
"fallback_sms": "SMS fallback",
"sms_only": "SMS only",
"sms_body_prefix": "Body:",
"sms_body_missing": "No SMS body",
"btn_edit_sms": "Edit SMS body"
},
"status_guide": {
"title": "Status badges",
"sendable_label": "Sendable",
"sendable": "Approved. Only this status can be linked to notifications and sent.",
"inspecting_label": "Inspecting",
"inspecting": "Kakao review in progress (2-3 business days).",
"pending_label": "Uninspected / Rejected",
"pending": "Cannot be sent yet. Request review or edit in the console."
"form": {
"modal_title": "Alimtalk Template — {name}",
"rejected_banner": "This template was rejected. Review the reasons below, edit, and request inspection again.",
"sender_profile": "Sender profile",
"sender_profile_placeholder": "Select a sender profile (reference)",
"sender_profile_hint": "Inspection requests use the sender profile key saved in the plugin settings.",
"category": "Category",
"category_placeholder": "Select a category",
"name": "Template name",
"message_type": "Message type",
"templateMessageType_options": {
"BA": "Basic",
"EX": "Extra info",
"AD": "Channel add",
"MI": "Mixed"
},
"emphasize_type": "Emphasis type",
"templateEmphasizeType_options": {
"NONE": "None",
"TEXT": "Text",
"IMAGE": "Image",
"ITEM_LIST": "Item list"
},
"emphasize_title": "Emphasis title",
"emphasize_subtitle": "Emphasis subtitle",
"image": "Image",
"image_hint": "jpg/png · up to 500KB · width ≥500px · 2:1 ratio (the URL is filled automatically after upload)",
"image_uploading": "Uploading image...",
"image_upload_failed": "Failed to upload the image.",
"content": "Body",
"extra": "Extra info",
"header": "Header",
"header_hint": "Optional header shown above the body (up to 16 characters)",
"buttons": "Buttons (up to 5)",
"button_add": "Add button",
"button_name": "Button name (up to 14 chars)",
"quick_replies": "Quick replies (up to 10)",
"quick_reply_add": "Add quick reply",
"link_mo": "Mobile link (required)",
"link_pc": "PC link (optional)",
"link_and": "Android scheme (required)",
"link_ios": "iOS scheme (required)",
"tel_number": "Phone number (required)",
"plugin_id": "Plugin ID (required)",
"item_highlight": "Item highlight (optional)",
"hl_title": "Highlight title",
"hl_description": "Highlight description",
"hl_image_url": "Thumbnail image URL (optional)",
"item_list": "Items (2–10)",
"item_list_hint": "At least 2 and up to 10 items. (title ≤6 chars, description ≤23 chars)",
"item_add": "Add item",
"item_title": "Title",
"item_description": "Description",
"item_summary": "Summary (price info)",
"summary_description": "Summary value (≤14 chars)",
"btn_remove_item": "Remove item",
"btn_remove_button": "Remove button",
"btn_remove_quick_reply": "Remove quick reply",
"btn_save": "Save",
"btn_save_request": "Save & request inspection",
"saved_toast": "Notification template saved.",
"requested_toast": "Inspection requested. Alimtalk delivery starts once approved."
},
"sms": {
"modal_title": "SMS Body — {name}",
"hint": "This body is shared by SMS fallback and SMS-only delivery. #{variables} are substituted at send time.",
"btn_save": "Save",
"saved_toast": "SMS body saved."
},
"cancel_approval": {
"modal_title": "Cancel Approval",
"warning": "Cancelling approval of '{name}' immediately stops alimtalk delivery for this notification. (SMS fallback and SMS-only delivery continue per the SMS settings.) Alimtalk resumes only after you edit, request inspection again, and get approved.",
"btn_confirm": "Cancel approval & edit",
"done_toast": "Approval cancelled. Edit and request inspection again."
},
"rejection": {
"modal_title": "Rejection Reasons — {name}",
"btn_close": "Close"
}
},
"manage": {
"guide": "Manage the Bizppurio template status of every notification at a glance. Composing and requesting inspection work the same as in the notification settings screen.",
"filter_all": "All statuses",
"search_placeholder": "Search by notification name/type",
"btn_search": "Search",
"total_count": "Total {count}",
"btn_compose": "Compose",
"btn_edit": "Edit",
"btn_delete": "Delete",
"empty": "No Bizppurio notification templates yet.",
"empty_hint": "Templates appear here once you compose an alimtalk template or configure SMS in the admin notification settings (Bizppurio tab).",
"sms_only": "Only",
"sms_fallback": "Fallback",
"owner": {
"core": "Core",
"module": "Module",
"plugin": "Plugin"
},
"columns": {
"notification": "Notification",
"owner": "Owner",
"status": "Alimtalk Status",
"sms": "SMS",
"requested_at": "Requested",
"synced_at": "Synced",
"actions": "Actions"
},
"delete": {
"modal_title": "Delete Template",
"warning": "Delete the Bizppurio template settings of '{name}'. The Kakao-side template is deleted together only when it is in a deletable state (registered/rejected).",
"btn_confirm": "Delete",
"done_with_kakao": "Template deleted. (including the Kakao-side template)",
"done_db_only": "Template settings deleted. The Kakao-side template remains due to its state."
}
},
"templates": {
"readiness": {
"title": "The settings below are required to use alimtalk templates",
"go_settings": "Go to settings",
"missing_label": "Missing items",
"api_key_missing": "Kakao management API key",
"sender_key_missing": "Alimtalk sender profile key",
"note": "Enter the items above in the Settings tab to compose templates and request inspection. Real delivery requires turning off test mode and an approved template."
},
"list": {
"refresh": "Refresh"
},
"link_type": {
"WL": "Web link",
"AL": "App link",
"DS": "Delivery tracking",
"BK": "Bot keyword",
"MD": "Message delivery",
"MD": "Message forward",
"AC": "Add channel",
"BC": "Consultation talk",
"BC": "Consult talk",
"BT": "Bot transfer",
"TN": "Call",
"MP": "Map",
"P1": "Secure image send",
"P2": "Privacy consent",
"P1": "Secure image",
"P2": "Personal info",
"P3": "One-click pay"
},
"actions": {
"detail": "Detail"
},
"detail": {
"title": "Template detail",
"close": "Close",
"buttons": "Buttons",
"extra": "Additional info",
"category": "Category",
"code": "Template code",
"content": "Template content",
"emphasize_type": "Template type",
"image_upload": "Attach image",
"subtitle_field": "Highlight subtitle",
"title_field": "Highlight title",
"type_image": "Image",
"type_none": "Basic",
"type_text": "Highlighted"
}
}
}
@@ -53,15 +53,7 @@
},
"sender_key": {
"label": "알림톡 발신프로필 키",
"hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다."
},
"template_cache_minutes": {
"label": "알림톡 내용 캐시 시간(분)",
"hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).",
"clear_cache": "캐시 초기화",
"clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.",
"clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"clear_cache_failed": "캐시 초기화에 실패했습니다."
"hint": "알림톡 발송·템플릿 신청에 사용하는 발신프로필 키(40자)입니다."
}
},
"report": {
@@ -71,13 +63,9 @@
"copy": "복사",
"copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
},
"cache": {
"section_title": "알림톡 발송 내용 캐시",
"section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다."
},
"tabs": {
"connection": "환경설정",
"templates": "알림톡 템플릿"
"templates": "알림 템플릿 관리"
},
"preparation": {
"intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.",
@@ -85,31 +73,16 @@
"sms_sender": "발신번호를 등록하세요.",
"kakao_label": "카카오 알림톡",
"kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.",
"kakao_template": "알림톡 템플릿을 등록하고 승인받으세요.",
"kakao_template": "알림톡 템플릿은 관리자 알림 설정(비즈뿌리오 탭)에서 작성해 검수를 신청합니다.",
"kakao_apikey": "고객센터에 API 키를 요청하세요.",
"console_link": "비즈뿌리오 콘솔 열기"
}
},
"channels": {
"bizppurio_tab": "비즈뿌리오"
},
"binding": {
"section_title": "카카오 알림톡 연동",
"list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.",
"section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.",
"modal_title": "알림톡 연결 · {name}",
"unbound": "미연결",
"unavailable": "사용 불가 — 재연결 필요",
"btn_connect": "연결",
"btn_change": "연결 변경",
"fallback_on": "SMS 대체 ON",
"fallback_off": "SMS 대체 OFF",
"connected_template": "연결 템플릿",
"none": "연결 안 함",
"no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.",
"templates_load_failed": "템플릿 목록을 불러오지 못했습니다. 플러그인 설정(자격증명)을 확인하세요.",
"fallback_sms": "실패 시 SMS로 대체발송",
"fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.",
"variables_hint": "제공 변수 (발송 시 자동 치환)",
"saved": "알림톡 연동을 저장했습니다.",
"save_error": "알림톡 연동 저장에 실패했습니다."
"variables_hint": "제공 변수 (클릭하면 본문 커서 위치에 삽입됩니다)"
},
"banner": {
"not_ready": "발송에 필요한 설정이 완료되지 않았습니다.",
@@ -124,70 +97,174 @@
"detail_title": "문자·알림톡 발송 결과",
"channel_label": "발송 채널: {channel}",
"sent_content_label": "실제 발송 내용",
"sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다."
"sent_content_hint": "알림톡은 승인 확정된 템플릿 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다."
},
"editor": {
"data_source": {
"dispatch_results": "비즈뿌리오 발송 결과"
}
},
"template": {
"tab_guide": "알림별로 알림톡 템플릿을 작성해 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 SMS·SMS 단독 발송도 이 화면에서 설정합니다.",
"status": {
"unwritten": "미작성",
"draft": "작성중",
"requested": "검수중",
"approved": "승인됨",
"rejected": "반려",
"stopped": "중지",
"blocked": "차단",
"dormant": "휴면"
},
"row": {
"alimtalk_label": "알림톡",
"requested_on": "{date} 신청",
"synced_on": "{date} 동기화",
"alimtalk_enabled": "발송 사용",
"btn_view_reason": "사유 보기",
"btn_compose": "알림톡 템플릿 작성",
"btn_edit": "수정",
"btn_edit_approved": "수정 (승인 취소)",
"btn_request": "검수 신청",
"btn_cancel_request": "신청 취소",
"btn_release": "휴면 해제",
"btn_refresh": "새로고침",
"requested_toast": "검수를 신청했습니다.",
"cancel_request_toast": "검수 신청을 취소했습니다.",
"released_toast": "휴면 상태를 해제했습니다.",
"synced_toast": "카카오 검수 상태를 동기화했습니다.",
"fallback_sms": "대체 SMS",
"sms_only": "SMS 단독",
"sms_body_prefix": "본문:",
"sms_body_missing": "SMS 본문 미설정",
"btn_edit_sms": "SMS 본문 편집"
},
"form": {
"modal_title": "알림톡 템플릿 — {name}",
"rejected_banner": "반려된 템플릿입니다. 아래 사유를 확인하고 수정 후 다시 신청하세요.",
"sender_profile": "발신프로필",
"sender_profile_placeholder": "발신프로필 선택(참고용)",
"sender_profile_hint": "검수 신청은 환경설정에 저장된 발신프로필 키로 진행됩니다.",
"category": "카테고리",
"category_placeholder": "카테고리 선택",
"name": "템플릿명",
"message_type": "메시지 유형",
"templateMessageType_options": {
"BA": "기본형",
"EX": "부가정보형",
"AD": "채널추가형",
"MI": "복합형"
},
"emphasize_type": "강조 유형",
"templateEmphasizeType_options": {
"NONE": "없음",
"TEXT": "강조표기",
"IMAGE": "이미지",
"ITEM_LIST": "아이템리스트"
},
"emphasize_title": "강조표기 타이틀",
"emphasize_subtitle": "강조표기 서브타이틀",
"image": "이미지",
"image_hint": "jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1 (업로드하면 URL 이 자동 기입됩니다)",
"image_uploading": "이미지 업로드 중...",
"image_upload_failed": "이미지 업로드에 실패했습니다.",
"content": "본문",
"extra": "부가정보",
"header": "헤더",
"header_hint": "본문 상단에 표기되는 최대 16자 문구(선택)",
"buttons": "버튼 (최대 5개)",
"button_add": "버튼 추가",
"button_name": "버튼명 (최대 14자)",
"quick_replies": "바로연결 (최대 10개)",
"quick_reply_add": "바로연결 추가",
"link_mo": "모바일 링크 (필수)",
"link_pc": "PC 링크 (선택)",
"link_and": "Android 스킴 (필수)",
"link_ios": "iOS 스킴 (필수)",
"tel_number": "전화번호 (필수)",
"plugin_id": "플러그인 ID (필수)",
"item_highlight": "아이템 하이라이트 (선택)",
"hl_title": "하이라이트 타이틀",
"hl_description": "하이라이트 설명",
"hl_image_url": "썸네일 이미지 URL (선택)",
"item_list": "아이템 목록 (2~10개)",
"item_list_hint": "아이템은 최소 2개, 최대 10개입니다. (제목 6자·설명 23자 이내)",
"item_add": "아이템 추가",
"item_title": "제목",
"item_description": "설명",
"item_summary": "요약(가격 정보)",
"summary_description": "요약 값 (14자 이내)",
"btn_remove_item": "아이템 삭제",
"btn_remove_button": "버튼 삭제",
"btn_remove_quick_reply": "바로연결 삭제",
"btn_save": "저장",
"btn_save_request": "저장 후 검수 신청",
"saved_toast": "알림 템플릿을 저장했습니다.",
"requested_toast": "검수를 신청했습니다. 승인되면 알림톡 발송이 시작됩니다."
},
"sms": {
"modal_title": "SMS 본문 — {name}",
"hint": "대체 SMS 와 SMS 단독 발송에 공통으로 사용하는 본문입니다. #{변수} 는 발송 시 자동 치환됩니다.",
"btn_save": "저장",
"saved_toast": "SMS 본문을 저장했습니다."
},
"cancel_approval": {
"modal_title": "승인 취소 확인",
"warning": "'{name}' 템플릿의 승인을 취소하면 즉시 이 알림의 알림톡 발송이 중단됩니다. (대체 SMS·SMS 단독 발송은 SMS 설정에 따라 계속됩니다.) 수정 후 다시 검수 신청해 승인을 받아야 알림톡 발송이 재개됩니다.",
"btn_confirm": "승인 취소 후 수정",
"done_toast": "승인을 취소했습니다. 수정 후 다시 검수 신청하세요."
},
"rejection": {
"modal_title": "반려 사유 — {name}",
"btn_close": "닫기"
}
},
"manage": {
"guide": "모든 알림의 비즈뿌리오 템플릿 상태를 한눈에 관리합니다. 작성·검수 신청은 알림 설정 화면과 동일하게 동작합니다.",
"filter_all": "전체 상태",
"search_placeholder": "알림 이름/유형 검색",
"btn_search": "검색",
"total_count": "총 {count}건",
"btn_compose": "작성",
"btn_edit": "수정",
"btn_delete": "삭제",
"empty": "등록된 비즈뿌리오 알림 템플릿이 없습니다.",
"empty_hint": "관리자 알림 설정의 비즈뿌리오 탭에서 알림톡 템플릿을 작성하거나 SMS 를 설정하면 여기에 표시됩니다.",
"sms_only": "단독",
"sms_fallback": "대체",
"owner": {
"core": "코어",
"module": "모듈",
"plugin": "플러그인"
},
"columns": {
"notification": "알림",
"owner": "소속",
"status": "알림톡 상태",
"sms": "SMS",
"requested_at": "신청일",
"synced_at": "동기화",
"actions": "관리"
},
"delete": {
"modal_title": "템플릿 삭제",
"warning": "'{name}' 의 비즈뿌리오 템플릿 설정을 삭제합니다. 카카오에 등록된 템플릿은 삭제 가능한 상태(등록/반려)일 때만 함께 삭제됩니다.",
"btn_confirm": "삭제",
"done_with_kakao": "템플릿을 삭제했습니다. (카카오측 템플릿 포함)",
"done_db_only": "템플릿 설정을 삭제했습니다. 카카오측 템플릿은 상태 제약으로 남아 있습니다."
}
},
"templates": {
"title": "알림톡 템플릿",
"description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
"readiness": {
"title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다",
"go_settings": "환경설정으로 이동",
"missing_label": "미설정 항목",
"api_key_missing": "카카오 관리 API 키",
"sender_key_missing": "알림톡 발신프로필 키",
"note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다."
},
"list_error": {
"title": "템플릿 목록을 불러오지 못했습니다"
},
"list_notice": {
"console_desc": "템플릿 등록·수정·검수는 비즈뿌리오 콘솔에서 진행해주세요.",
"console_link": "비즈뿌리오 콘솔 열기"
"note": "위 항목을 환경설정 탭에서 입력하면 템플릿 작성·검수 신청이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 승인된 템플릿만 사용할 수 있습니다."
},
"list": {
"refresh": "새로고침",
"search": "검색",
"search_placeholder": "템플릿명 검색 (2~50자)",
"filter_all": "전체 상태",
"empty": "등록된 알림톡 템플릿이 없습니다.",
"empty_hint": "비즈뿌리오 콘솔에서 등록한 템플릿이 여기에 표시됩니다.",
"load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.",
"columns": {
"no": "번호",
"name": "템플릿명",
"code": "코드",
"status": "상태",
"requested_at": "등록요청일",
"processed_at": "처리일",
"actions": "내용"
}
},
"status": {
"sendable": "발송가능",
"inspecting": "검수중",
"rejected": "반려",
"uninspected": "미검수",
"stopped": "중지",
"blocked": "차단",
"dormant": "휴면",
"unknown": "알 수 없음"
},
"status_sub": {
"rdy": "(사용전)"
},
"status_guide": {
"title": "상태 배지",
"sendable_label": "발송가능",
"sendable": "승인 완료. 이 상태만 알림에 연결·발송할 수 있습니다.",
"inspecting_label": "검수중",
"inspecting": "카카오 검수 진행 중 (영업일 2~3일).",
"pending_label": "미검수·반려",
"pending": "아직 발송할 수 없습니다. 콘솔에서 검수요청·수정하세요."
"refresh": "새로고침"
},
"link_type": {
"WL": "웹링크",
@@ -203,25 +280,6 @@
"P1": "이미지 보안전송",
"P2": "개인정보이용",
"P3": "원클릭 결제"
},
"actions": {
"detail": "상세"
},
"detail": {
"title": "템플릿 상세",
"close": "닫기",
"buttons": "버튼",
"extra": "부가정보",
"category": "카테고리",
"code": "템플릿 코드",
"content": "템플릿 내용",
"emphasize_type": "템플릿 유형",
"image_upload": "이미지 첨부",
"subtitle_field": "보조 문구",
"title_field": "강조표기 문구",
"type_image": "이미지형",
"type_none": "기본형",
"type_text": "강조표기형"
}
}
}
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,11 @@ use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
* 하여 카카오가 준 실패 사유(message)와 결과 코드를 그대로 422 로 반환한다. 운영자가 반려/차단
* 사유를 화면에서 바로 확인할 수 있게 한다. 알림톡 템플릿 관리(Phase 5)와 연동(Phase 6)
* 컨트롤러가 공유한다.
*
* errors 키는 `bizppurio_message` 로 고정한다 — 이 플러그인의 다른 kapi 경로
* (BizppurioTemplateController·TokenCheckController)와 화면 소비처, API 문서가 모두 그 키를
* 읽는다. 이 트레이트만 다른 키를 쓰면 카테고리·발신프로필 조회 실패에서만 사유 원문이
* 버려지고 고정 문구만 노출된다.
*/
trait GuardsKakaoRequests
{
@@ -33,7 +38,7 @@ trait GuardsKakaoRequests
'sirsoft-message_bizppurio::messages.error.kakao_request_failed',
422,
[
'kakao_message' => $e->getMessage(),
'bizppurio_message' => $e->getMessage(),
'result_code' => $e->getResultCode(),
],
);
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Console;
use Illuminate\Console\Command;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTemplateService;
/**
* 알림톡 템플릿 검수 상태 동기화 커맨드 (#597 §3.4).
*
* 검수중(requested) 행이 있을 때만 카카오 관리 API 를 호출해 상태를 일괄 대조한다
* (senderKey 별 template/list 1회 — 행별 detail 호출 없음, 레이트리밋 보호). 반려로
* 전이한 행만 사유(comments) 확보를 위해 detail 을 추가 호출한다.
*
* plugin.php::getSchedules() 가 30분 주기로 등록하며, 화면의 수동 [새로고침]이 동등
* 기능을 제공하므로 cron 미가동 환경에서도 승인 확인이 가능하다.
*/
class SyncTemplateStatusCommand extends Command
{
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'bizppurio:sync-template-status';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '검수중인 비즈뿌리오 알림톡 템플릿의 카카오 검수 상태를 동기화합니다';
/**
* 커맨드를 실행합니다.
*
* @param BizppurioTemplateService $service 템플릿 라이프사이클 서비스
* @return int 종료 코드 (항상 SUCCESS — 조회 실패는 서비스가 로그로 남기고 다음 주기에 재시도)
*/
public function handle(BizppurioTemplateService $service): int
{
$result = $service->syncRequested();
if ($result['checked'] === 0) {
$this->info('검수중(requested) 템플릿이 없어 카카오 조회를 건너뜁니다.');
return self::SUCCESS;
}
$this->info(sprintf(
'검수중 템플릿 %d건 점검, %d건 상태 전이.',
$result['checked'],
$result['transitioned'],
));
return self::SUCCESS;
}
}
@@ -8,68 +8,33 @@ use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\AlimtalkTemplateListRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림톡 템플릿 조회 컨트롤러 (Phase 5).
* 알림톡 작성 모달 참조 조회 컨트롤러 (#597).
*
* 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회한다(목록·상세·카테고리·발신프로필).
* 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임하며, 이 화면은 조회 + 알림 연결만
* 담당한다. 템플릿은 DB 에 저장하지 않고 목록/상세를 매 요청 실시간 조회한다.
* 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비하는 kapi 조회를
* 위임한다. 템플릿 목록/상세의 실시간 조회 화면(구 Phase 5)은 DB 기반 라이프사이클
* (BizppurioTemplateController)로 대체되어 제거됐다.
*
* 권한(라우트 미들웨어):
* - 조회(list/detail/categories/profiles): sirsoft-message_bizppurio.messaging.view
* 권한(라우트 미들웨어): 조회(categories/profiles) = messaging.view
*
* kapi 실패는 BizppurioApiException 으로 전달되므로, 각 액션에서 catch 하여 카카오가 준
* 실패 사유(message)를 그대로 422 로 반환한다(운영자가 조회 실패 원인을 바로 확인).
* kapi 실패는 BizppurioApiException 으로 전달되므로, guard 로 감싸 카카오가 준
* 실패 사유(message)를 그대로 422 로 반환한다.
*/
class AlimtalkTemplateController extends AdminBaseController
{
use GuardsKakaoRequests;
/**
* @param AlimtalkTemplateService $service 알림톡 템플릿 서비스
* @param NotificationBindingService $bindings 연동 서비스(발송 내용 캐시 초기화 위임)
* @param AlimtalkTemplateService $service 발신프로필·카테고리 조회 서비스
*/
public function __construct(
private readonly AlimtalkTemplateService $service,
private readonly NotificationBindingService $bindings,
) {
parent::__construct();
}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* 쿼리: status(templateStatus)·keyword(최대 50자)·page·count
*
* @param AlimtalkTemplateListRequest $request 검증된 목록 필터
* @return JsonResponse data 에 templates·pagination
*/
public function index(AlimtalkTemplateListRequest $request): JsonResponse
{
return $this->guard(function () use ($request) {
$result = $this->service->list($request->filters());
return ResponseHelper::success('messages.success', $result);
});
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
* @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세
*/
public function show(string $templateCode): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'template' => $this->service->detail($templateCode),
]));
}
/**
* 템플릿 등록에 사용할 카테고리 전체를 조회합니다.
*
@@ -93,26 +58,4 @@ class AlimtalkTemplateController extends AdminBaseController
'profiles' => $this->service->senderProfiles(),
]));
}
/**
* 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시
* 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서
* 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음.
*
* @return JsonResponse data.cleared 에 초기화한 캐시 수
*/
public function clearCache(): JsonResponse
{
$cleared = $this->bindings->clearTemplateContentCache();
return ResponseHelper::success('messages.cache.cleared', [
'cleared' => $cleared,
]);
}
// kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는
// GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유).
// 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다.
}
@@ -0,0 +1,380 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateImageRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateListRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreBizppurioTemplateRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioDeliveryRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioTemplateRequest;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTemplateService;
/**
* 비즈뿌리오 알림 템플릿 라이프사이클 컨트롤러 (#597 §3.2).
*
* 시스템 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화의 전 흐름을
* 담당한다. 발송 판정은 DB 가 유일한 근거이며(실시간 조회 폐지), 카카오와의 상태 정합은
* 스케줄러(bizppurio:sync-template-status)와 수동 sync 가 유지한다.
*
* 권한(라우트 미들웨어):
* - 조회(index/map/show): sirsoft-message_bizppurio.messaging.view
* - 변경(store/update/request/cancel/sync/image/destroy): sirsoft-message_bizppurio.messaging.manage
*
* 실패 응답 규약:
* - kapi 실패(BizppurioApiException) → 422 + errors.bizppurio_message(카카오 사유 원문)·errors.result_code
* - 상태 전이 위반(BizppurioTemplateStateException) → 422 + 예외의 메시지 키 해석
*/
class BizppurioTemplateController extends AdminBaseController
{
/**
* @param BizppurioTemplateService $service 템플릿 라이프사이클 서비스
*/
public function __construct(
private readonly BizppurioTemplateService $service,
) {
parent::__construct();
}
/**
* 템플릿 DB 목록을 조회합니다 (관리 화면 — 알림 정의 라벨·소속 조인).
*
* 쿼리: status(우리 상태 enum)·search(알림 유형/이름)·page·per_page
*
* @param BizppurioTemplateListRequest $request 검증된 목록 필터
* @return JsonResponse data 에 templates·pagination
*/
public function index(BizppurioTemplateListRequest $request): JsonResponse
{
$paginator = $this->service->list($request->filters(), $request->page(), $request->perPage());
return ResponseHelper::success('messages.success', [
'templates' => collect($paginator->items())
->map(fn (BizppurioTemplate $row) => $this->listRow($row))
->all(),
'pagination' => [
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
'has_more_pages' => $paginator->hasMorePages(),
'current_page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
],
]);
}
/**
* 알림 설정 탭 행 표시용 요약 맵을 조회합니다 (notification_type 키).
*
* 알림 설정 3면(코어/게시판/이커머스)의 행 하단 UI 가 소비한다. 행 수는 알림 정의
* 수에 묶인 설정성 데이터라 페이지네이션 없이 전량 내려준다.
*
* @return JsonResponse data.templates 에 notification_type 키 요약 맵
*/
public function map(): JsonResponse
{
return ResponseHelper::success('messages.success', [
'templates' => $this->service->summaryMap(),
]);
}
/**
* 템플릿 상세를 조회합니다 (content·inspection_detail 포함).
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 상세
*/
public function show(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return ResponseHelper::success('messages.success', [
'template' => $this->detailRow($template),
]);
}
/**
* 템플릿을 생성합니다 (status=draft).
*
* @param StoreBizppurioTemplateRequest $request 검증된 생성 데이터
* @return JsonResponse data.template 에 생성된 행
*/
public function store(StoreBizppurioTemplateRequest $request): JsonResponse
{
$template = $this->service->create($request->validated());
return ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.created',
['template' => $this->detailRow($template)],
201,
);
}
/**
* 템플릿을 수정합니다 (content 변경은 draft/rejected 만).
*
* @param UpdateBizppurioTemplateRequest $request 검증된 수정 데이터
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function update(UpdateBizppurioTemplateRequest $request, int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.updated',
['template' => $this->detailRow($this->service->update($template, $request->validated()))],
));
}
/**
* 발송 설정(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)을 upsert 합니다.
*
* 알림 설정 탭 행 하단 토글의 즉시 저장 경로. 대상 행이 없으면 draft 로 생성한다.
*
* @param UpdateBizppurioDeliveryRequest $request 검증된 발송 설정
* @return JsonResponse data.template 에 저장된 행
*/
public function upsertDelivery(UpdateBizppurioDeliveryRequest $request): JsonResponse
{
$template = $this->service->upsertDelivery(
(string) $request->route('notificationType'),
$request->deliveryData(),
);
return ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.updated',
['template' => $this->detailRow($template)],
);
}
/**
* 검수를 신청합니다 (채번 → kapi add/update → request → status=requested).
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function requestInspection(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.requested',
['template' => $this->detailRow($this->service->requestInspection($template))],
));
}
/**
* 검수 신청을 취소합니다 (REQ→REG, status=draft 복귀).
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function cancelRequest(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.request_cancelled',
['template' => $this->detailRow($this->service->cancelRequest($template))],
));
}
/**
* 승인을 취소합니다 (승인→등록 복귀 — 알림톡 발송 즉시 차단).
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function cancelApproval(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.approval_cancelled',
['template' => $this->detailRow($this->service->cancelApproval($template))],
));
}
/**
* 휴면(DMT) 템플릿을 해제합니다.
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function release(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.released',
['template' => $this->detailRow($this->service->releaseDormant($template))],
));
}
/**
* 단건 상태를 카카오와 동기화합니다 (수동 [새로고침]).
*
* @param int $id 템플릿 PK
* @return JsonResponse data.template 에 갱신된 행
*/
public function sync(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.synced',
['template' => $this->detailRow($this->service->sync($template))],
));
}
/**
* 이미지형 템플릿용 이미지를 업로드합니다 (kapi 업로드 프록시).
*
* @param BizppurioTemplateImageRequest $request 검증된 이미지 파일
* @return JsonResponse data.url 에 카카오 이미지 URL
*/
public function uploadImage(BizppurioTemplateImageRequest $request): JsonResponse
{
return $this->guardLifecycle(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.image_uploaded',
['url' => $this->service->uploadImage($request->file('image'))],
));
}
/**
* 템플릿을 삭제합니다 (카카오측은 삭제 가능 상태일 때만 동반 삭제).
*
* @param int $id 템플릿 PK
* @return JsonResponse data 에 kakao_deleted·kakao_skip_reason
*/
public function destroy(int $id): JsonResponse
{
$template = $this->service->find($id);
if ($template === null) {
return ResponseHelper::error('messages.not_found', 404);
}
return ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.deleted',
$this->service->delete($template),
);
}
/**
* 라이프사이클 액션을 실행하고 도메인 실패를 422 로 매핑합니다.
*
* - BizppurioApiException: 카카오가 준 사유 원문(bizppurio_message)·결과코드를 errors 로 노출
* (관리자 전용 면 — 조치 근거 제공, TokenCheckController 규약과 동일)
* - BizppurioTemplateStateException: 예외가 든 메시지 키를 해석해 422
* 그 외 예외는 잡지 않는다 — 인프라 장애·코드 결함은 500 으로 드러나야 한다.
*
* @param callable():JsonResponse $callback 라이프사이클 액션
* @return JsonResponse 성공 응답 또는 422
*/
private function guardLifecycle(callable $callback): JsonResponse
{
try {
return $callback();
} catch (BizppurioApiException $e) {
return ResponseHelper::error(
'sirsoft-message_bizppurio::messages.error.kakao_request_failed',
422,
[
'bizppurio_message' => $e->getMessage(),
'result_code' => $e->getResultCode(),
],
);
} catch (BizppurioTemplateStateException $e) {
return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams());
}
}
/**
* 관리 화면 목록 행을 직렬화합니다 (조인된 정의 라벨·소속 포함, 대형 JSON 제외).
*
* @param BizppurioTemplate $row 목록 행 (definition_* 조인 별칭 포함)
* @return array<string, mixed> 직렬화된 행
*/
private function listRow(BizppurioTemplate $row): array
{
$definitionName = $row->getAttribute('definition_name');
$definitionVariables = $row->getAttribute('definition_variables');
return [
'id' => $row->id,
'notification_type' => $row->notification_type,
'definition_name' => is_string($definitionName) ? (json_decode($definitionName, true) ?: null) : null,
'definition_variables' => is_string($definitionVariables) ? (json_decode($definitionVariables, true) ?: []) : [],
'definition_extension_type' => $row->getAttribute('definition_extension_type'),
'definition_extension_identifier' => $row->getAttribute('definition_extension_identifier'),
'alimtalk_enabled' => $row->alimtalk_enabled,
'template_code' => $row->template_code,
'status' => $row->status->value,
'has_content' => (bool) $row->getAttribute('has_content'),
'requested_at' => $row->requested_at?->toIso8601String(),
'approved_at' => $row->approved_at?->toIso8601String(),
'last_synced_at' => $row->last_synced_at?->toIso8601String(),
'fallback_sms_enabled' => $row->fallback_sms_enabled,
'sms_only' => $row->sms_only,
'is_active' => $row->is_active,
];
}
/**
* 템플릿 상세를 직렬화합니다 (content·승인 스냅샷·반려 사유 포함).
*
* @param BizppurioTemplate $template 대상 행
* @return array<string, mixed> 직렬화된 상세
*/
private function detailRow(BizppurioTemplate $template): array
{
return [
'id' => $template->id,
'notification_type' => $template->notification_type,
'alimtalk_enabled' => $template->alimtalk_enabled,
'template_code' => $template->template_code,
'sender_key' => $template->sender_key,
'content' => $template->content,
'approved_content' => $template->approved_content,
'status' => $template->status->value,
'is_approved' => $template->status === BizppurioTemplateStatus::Approved,
'inspection_detail' => $template->inspection_detail,
'requested_at' => $template->requested_at?->toIso8601String(),
'approved_at' => $template->approved_at?->toIso8601String(),
'last_synced_at' => $template->last_synced_at?->toIso8601String(),
'fallback_sms_enabled' => $template->fallback_sms_enabled,
'sms_body' => $template->sms_body,
'sms_only' => $template->sms_only,
'is_active' => $template->is_active,
];
}
}
@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreNotificationBindingRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림↔알림톡 템플릿 연동 컨트롤러 (계획서 §6-2, Phase 6 재설계).
*
* 알림 설정 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS
* 대체 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이
* 컨트롤러로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼과 무관해
* 코어 템플릿 무오염). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다.
*
* - index/all: 현재 알림톡 연동 맵(전용 칸 프리필)
* - approvedTemplates: 연결 가능(승인) 템플릿 드롭다운 옵션
* - store: 연결 템플릿·SMS 대체 즉시 저장(빈 코드=해제)
*
* 권한(라우트 미들웨어): 조회 = messaging.view, 저장 = messaging.manage
*/
class NotificationBindingController extends AdminBaseController
{
use GuardsKakaoRequests;
/**
* @param NotificationBindingService $service 연동 서비스
*/
public function __construct(
private readonly NotificationBindingService $service,
) {
parent::__construct();
}
/**
* 알림톡 채널의 현재 연동 맵을 반환합니다 (전용 칸 프리필).
*
* notification_type → 연동 정보(연결 템플릿·SMS 대체) 맵. 전용 칸이 편집 대상 알림의
* type 으로 조회해 드롭다운·토글 초기값을 채운다.
*
* @return JsonResponse data.bindings 에 notification_type 키의 연동 맵
*/
public function index(): JsonResponse
{
// 알림톡 탭 진입 표시용 — 카카오 승인 목록과 대조해 소실(발송 불가) 연동을 함께 표시한다.
// 카카오 조회 실패 시 서비스가 판정을 생략하므로 이 조회가 목록 표시를 막지 않는다.
return ResponseHelper::success('messages.success', [
'bindings' => $this->service->all(withAvailability: true),
]);
}
/**
* 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운).
*
* kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다.
*
* @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422
*/
public function approvedTemplates(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'templates' => $this->service->approvedTemplates(),
]));
}
/**
* 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출).
*
* 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 —
* 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만
* 의미가 있다.
*
* @param StoreNotificationBindingRequest $request 검증된 연동 입력
* @return JsonResponse 저장 결과 (해제 시에도 200)
*/
public function store(StoreNotificationBindingRequest $request): JsonResponse
{
$validated = $request->validated();
return $this->guard(function () use ($validated) {
$this->service->applyFromTemplateSave(
$validated['notification_type'],
$validated['template_code'] ?? null,
$validated['template_name'] ?? null,
(bool) ($validated['fallback_sms_enabled'] ?? false),
);
return ResponseHelper::success('messages.binding.saved', [
'bindings' => $this->service->all(),
]);
});
}
}
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 비즈뿌리오 알림톡 템플릿 라이프사이클 상태 (#597 §3.4).
*
* G7 이 관리하는 알림톡 템플릿의 상태다. 카카오 관리 API(kapi)의 serviceStatus 어휘를
* 우리 상태로 환원하는 매핑을 단일 출처로 보유한다(동기화 커맨드·수동 sync 가 공유).
*
* kapi serviceStatus → 우리 상태:
* - REG(등록: 검수 전·검수취소·승인취소 복귀 포함) → Draft
* - REQ(검수중) → Requested
* - REJ(반려) → Rejected
* - RDY(발송전)·ACT(정상) → Approved (발송 가능)
* - STP(중지) → Stopped
* - BLK(차단) → Blocked
* - DMT(휴면) → Dormant
*/
enum BizppurioTemplateStatus: string
{
case Draft = 'draft';
case Requested = 'requested';
case Approved = 'approved';
case Rejected = 'rejected';
case Stopped = 'stopped';
case Blocked = 'blocked';
case Dormant = 'dormant';
/**
* kapi serviceStatus 코드를 우리 상태로 환원합니다.
*
* 알 수 없는 코드는 null 을 반환한다 — 동기화 호출측이 상태를 덮어쓰지 않고
* 건너뛰게 하여, 카카오 어휘 확장 시 기존 상태가 오염되지 않게 한다.
*
* @param string $serviceStatus kapi serviceStatus (REG/REQ/REJ/RDY/ACT/STP/BLK/DMT)
* @return self|null 매핑된 상태, 알 수 없는 코드면 null
*/
public static function tryFromServiceStatus(string $serviceStatus): ?self
{
return match ($serviceStatus) {
'REG' => self::Draft,
'REQ' => self::Requested,
'REJ' => self::Rejected,
'RDY', 'ACT' => self::Approved,
'STP' => self::Stopped,
'BLK' => self::Blocked,
'DMT' => self::Dormant,
default => null,
};
}
/**
* kapi 상세 응답(status/inspectionStatus/block/dormant)에서 serviceStatus 를 유도합니다.
*
* 상세 조회(template/detail)는 serviceStatus 대신 status(S/A/R)+inspectionStatus
* (REG/REQ/REJ/APR)+block/dormant 를 내려주므로 목록과 동일한 어휘로 환원한다.
* serviceStatus 필드가 이미 있으면 그대로 사용한다.
*
* @param array<string, mixed> $detail kapi 템플릿 상세(또는 목록) 행
* @return string serviceStatus 코드
*/
public static function serviceStatusFromDetail(array $detail): string
{
if (! empty($detail['serviceStatus'])) {
return (string) $detail['serviceStatus'];
}
$inspection = (string) ($detail['inspectionStatus'] ?? '');
$status = (string) ($detail['status'] ?? '');
$block = (bool) ($detail['block'] ?? false);
$dormant = (bool) ($detail['dormant'] ?? false);
return match (true) {
$block => 'BLK',
$dormant => 'DMT',
$inspection === 'REQ' => 'REQ',
$inspection === 'REJ' => 'REJ',
$inspection === 'APR' && $status === 'S' => 'STP',
$inspection === 'APR' && $status === 'A' => 'ACT',
$inspection === 'APR' => 'RDY',
default => 'REG',
};
}
/**
* 발송 가능 상태(승인) 여부를 반환합니다.
*
* @return bool 승인 상태면 true
*/
public function isApproved(): bool
{
return $this === self::Approved;
}
/**
* 카카오 등록 내용(content) 수정·삭제가 가능한 상태인지 반환합니다.
*
* kapi 는 status R + inspectionStatus REG/REJ 에서만 update/delete 를 허용한다(부록 A-4).
* 우리 상태로는 Draft(등록·검수취소·승인취소 복귀)와 Rejected(반려)가 이에 해당한다.
*
* @return bool 수정 가능 상태면 true
*/
public function allowsContentEdit(): bool
{
return $this === self::Draft || $this === self::Rejected;
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Exceptions;
use RuntimeException;
/**
* 비즈뿌리오 알림 템플릿 상태 전이 위반 예외 (#597).
*
* 허용되지 않은 상태에서의 수정·삭제·검수 신청 등 라이프사이클 규칙 위반을 담는다.
* 컨트롤러는 이 예외를 도메인 실패로 구분해 422 로 응답하고, 그 외 예외는 서버
* 결함으로 보아 500 을 반환한다. 메시지는 번역문이 아닌 키+치환 파라미터로 보관해
* 응답 시점에 해석한다(예외 → 응답 매핑 규정).
*/
class BizppurioTemplateStateException extends RuntimeException
{
/**
* @param string $messageKey 다국어 메시지 키 (sirsoft-message_bizppurio::messages.*)
* @param array<string, mixed> $replace 메시지 치환 파라미터
*/
public function __construct(
private readonly string $messageKey,
private readonly array $replace = [],
) {
parent::__construct(__($messageKey, $replace));
}
/**
* 다국어 메시지 키를 반환합니다.
*
* @return string 다국어 메시지 키
*/
public function getMessageKey(): string
{
return $this->messageKey;
}
/**
* 메시지 치환 파라미터를 반환합니다.
*
* @return array<string, mixed> 치환 파라미터
*/
public function getMessageParams(): array
{
return $this->replace;
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 알림톡 템플릿 목록 실시간 조회 검증 (Phase 5).
*
* 목록은 카카오 관리 API(kapi) 위임 조회이므로 값 해석은 kapi 가 담당한다. 여기서는
* 형태(문자열/정수)와 상한만 확인해 비정상 형태(배열 주입 등)의 전달을 차단한다.
* status 값 어휘는 kapi 의 templateStatus 정의를 따르므로 서버에서 닫힌 집합으로
* 좁히지 않는다(kapi 스펙 변경 시 화면만 갱신하면 되도록).
*/
class AlimtalkTemplateListRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.view)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['nullable', 'string', 'max:30'],
'keyword' => ['nullable', 'string', 'max:50'],
'page' => ['nullable', 'integer', 'min:1'],
'count' => ['nullable', 'integer', 'min:1'],
];
}
/**
* 서비스 list() 에 전달할 필터 배열을 반환합니다.
*
* @return array<string, mixed> status·keyword·page·count (미전달 키는 null)
*/
public function filters(): array
{
return [
'status' => $this->validated('status'),
'keyword' => $this->validated('keyword'),
'page' => $this->validated('page'),
'count' => $this->validated('count'),
];
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
/**
* 이미지형 알림톡 템플릿 이미지 업로드 검증 (#597 §3.2 · 부록 A-7).
*
* kapi 이미지 업로드 제약(jpg/png · ≤500KB · 가로 ≥500px · 가로:세로 2:1)을 프록시
* 단계에서 사전 검증한다 — kapi 왕복 없이 즉시 인라인 오류를 돌려준다. 비율은 Laravel
* dimensions:ratio 가 부동소수 오차에 관대하지 않아 after 훅에서 직접 판정한다.
*/
class BizppurioTemplateImageRequest extends FormRequest
{
/** 최대 파일 크기 (KB) — kapi 제약 500KB */
private const MAX_KILOBYTES = 500;
/** 최소 가로 픽셀 — kapi 제약 500px */
private const MIN_WIDTH = 500;
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'image' => [
'required',
'file',
'mimes:jpg,jpeg,png',
'max:'.self::MAX_KILOBYTES,
'dimensions:min_width='.self::MIN_WIDTH,
],
];
}
/**
* 가로:세로 = 2:1 비율을 after 훅으로 검증합니다.
*
* @param Validator $validator 검증기
*/
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $v) {
$file = $this->file('image');
if ($file === null || ! $file->isValid()) {
return;
}
$size = @getimagesize((string) $file->getRealPath());
if ($size === false) {
return;
}
[$width, $height] = $size;
if ($height <= 0 || abs(($width / $height) - 2.0) > 0.01) {
$v->errors()->add('image', __('sirsoft-message_bizppurio::messages.validation.image_ratio_invalid'));
}
});
}
/**
* 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
public function attributes(): array
{
$label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
return [
'image' => $label('image'),
];
}
}
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
/**
* 비즈뿌리오 알림 템플릿 DB 목록 조회 검증 (#597 §3.6 관리 화면).
*/
class BizppurioTemplateListRequest extends FormRequest
{
/** 목록 기본 페이지 크기 */
private const DEFAULT_PER_PAGE = 20;
/** 목록 최대 페이지 크기 */
private const MAX_PER_PAGE = 100;
/**
* 권한은 라우트 미들웨어(messaging.view)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['nullable', Rule::enum(BizppurioTemplateStatus::class)],
'search' => ['nullable', 'string', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_PER_PAGE],
];
}
/**
* 저장소 필터 배열을 반환합니다.
*
* @return array<string, mixed> status / search
*/
public function filters(): array
{
return [
'status' => $this->validated('status'),
'search' => $this->validated('search'),
];
}
/**
* 요청 페이지 번호를 반환합니다.
*
* @return int 페이지 번호 (기본 1)
*/
public function page(): int
{
return max(1, (int) ($this->validated('page') ?? 1));
}
/**
* 요청 페이지 크기를 반환합니다.
*
* @return int 페이지 크기 (기본 20, 최대 100)
*/
public function perPage(): int
{
return min(self::MAX_PER_PAGE, max(1, (int) ($this->validated('per_page') ?? self::DEFAULT_PER_PAGE)));
}
/**
* 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
public function attributes(): array
{
$label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
return [
'status' => $label('status'),
'search' => $label('search'),
'page' => $label('page'),
'per_page' => $label('per_page'),
];
}
}
@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests\Concerns;
use App\Rules\TranslatableField;
use Illuminate\Validation\Validator;
/**
* 알림톡 템플릿 카카오 등록 페이로드(content) 검증 규칙 (#597 §3.2 매트릭스).
*
* Store/Update 가 동일 매트릭스를 공유한다. 문서(부록 A-2·A-3)에 수치가 명시된 제약만
* 강제하고, 수치 미기재 세부(강조표기 타이틀 길이 등)는 kapi 를 최종 게이트로 위임한다 —
* 실패 사유 원문이 errors 로 표면화되므로 이중 판정을 만들지 않는다.
*
* content 자체는 선택이다(SMS 단독 알림은 알림톡 content 없이 저장 가능). content 를
* 보냈을 때만 유형별 조건부 필수·길이 제약이 적용된다.
*/
trait ValidatesTemplateContent
{
/** 버튼 linkType 허용값 (부록 A-3) */
private const BUTTON_LINK_TYPES = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP'];
/** 바로연결 linkType 허용값 (부록 A-3 — WL/AL/BK/MD/BC/BT 만) */
private const QUICK_REPLY_LINK_TYPES = ['WL', 'AL', 'BK', 'MD', 'BC', 'BT'];
/**
* content 하위 필드의 표시용 라벨을 반환합니다 (FormRequest::attributes 병합용).
*
* 미지정 시 Laravel 이 `content.templateItem.list.0.title` 같은 경로를 그대로 노출해
* 운영자에게 영문 내부 식별자가 보인다. 배열 항목은 `*` 자리표시자로 선언하면
* 인덱스가 붙은 실제 경로에도 적용된다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
private function contentAttributes(): array
{
$label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
return [
'content' => $label('content'),
'content.templateName' => $label('template_name'),
'content.templateMessageType' => $label('message_type'),
'content.templateEmphasizeType' => $label('emphasize_type'),
'content.templateContent' => $label('template_content'),
'content.templatePreviewMessage' => $label('preview_message'),
'content.categoryCode' => $label('category_code'),
'content.securityFlag' => $label('security_flag'),
'content.templateExtra' => $label('extra'),
'content.templateTitle' => $label('title'),
'content.templateSubtitle' => $label('subtitle'),
'content.templateHeader' => $label('header'),
'content.templateImageName' => $label('image_name'),
'content.templateImageUrl' => $label('image_url'),
'content.templateItem' => $label('item'),
'content.templateItem.list' => $label('item_list'),
'content.templateItem.list.*.title' => $label('item_title'),
'content.templateItem.list.*.description' => $label('item_description'),
'content.templateItem.summary' => $label('summary'),
'content.templateItem.summary.title' => $label('summary_title'),
'content.templateItem.summary.description' => $label('summary_description'),
'content.templateItemHighlight' => $label('highlight'),
'content.templateItemHighlight.title' => $label('highlight_title'),
'content.templateItemHighlight.description' => $label('highlight_description'),
'content.templateItemHighlight.imageUrl' => $label('highlight_image_url'),
'content.templateRepresentLink' => $label('represent_link'),
'content.templateRepresentLink.linkMo' => $label('link_mo'),
'content.templateRepresentLink.linkPc' => $label('link_pc'),
'content.templateRepresentLink.linkAnd' => $label('link_and'),
'content.templateRepresentLink.linkIos' => $label('link_ios'),
'content.buttons' => $label('buttons'),
'content.buttons.*.name' => $label('button_name'),
'content.buttons.*.linkType' => $label('button_link_type'),
'content.buttons.*.linkMo' => $label('link_mo'),
'content.buttons.*.linkPc' => $label('link_pc'),
'content.buttons.*.linkAnd' => $label('link_and'),
'content.buttons.*.linkIos' => $label('link_ios'),
'content.buttons.*.telNumber' => $label('tel_number'),
'content.buttons.*.pluginId' => $label('plugin_id'),
'content.quickReplies' => $label('quick_replies'),
'content.quickReplies.*.name' => $label('quick_reply_name'),
'content.quickReplies.*.linkType' => $label('quick_reply_link_type'),
'content.quickReplies.*.linkMo' => $label('link_mo'),
'content.quickReplies.*.linkPc' => $label('link_pc'),
'content.quickReplies.*.linkAnd' => $label('link_and'),
'content.quickReplies.*.linkIos' => $label('link_ios'),
];
}
/**
* SMS 본문(다국어 맵)의 검증 규칙을 반환합니다 (#597 §14.3).
*
* sms_body 는 로케일별 본문 맵이다. 허용 로케일 판정·길이 검사는 코어 TranslatableField
* 규칙에 위임한다 — 로케일 목록(`app.translatable_locales`)은 활성 언어팩에 따라 부팅마다
* 달라지는 가변값이고, 그 규칙이 이미 "비활성 언어팩의 기존 번역은 지우지 않는다" 는
* 판정까지 담고 있다. 여기서 다시 조립하면 같은 판정이 두 벌이 된다.
*
* Store/Update/Delivery 세 경로가 같은 규칙을 써야 한다 — 한 곳만 느슨하면 그 경로가
* 우회로가 된다.
*
* @return array<string, array<int, mixed>> 필드 경로 → 규칙
*/
private function smsBodyRules(): array
{
return [
'sms_body' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 2000)],
];
}
/**
* 발송 설정 필드의 표시용 라벨을 반환합니다 (FormRequest::attributes 병합용).
*
* @return array<string, string> 필드 경로 → 라벨
*/
private function deliveryAttributes(): array
{
$label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
return [
'notification_type' => $label('notification_type'),
'alimtalk_enabled' => $label('alimtalk_enabled'),
'fallback_sms_enabled' => $label('fallback_sms_enabled'),
'sms_body' => $label('sms_body'),
'sms_only' => $label('sms_only'),
'is_active' => $label('is_active'),
];
}
/**
* 유형과 무관한 content 필드를 잘라내 kapi 등록 페이로드를 정돈합니다.
*
* 화면(작성 모달)은 폼 상태 전체를 그대로 전송한다 — 유형 전환 잔여값(강조표기
* 타이틀·이미지 URL·아이템리스트 등)을 클라이언트가 조건부로 빼는 대신, 서버가
* 선택된 templateMessageType/templateEmphasizeType 기준으로 무관 필드·빈 값을
* 제거한다. 저장된 content 가 곧 kapi add/update 페이로드이므로(§3.1) 이 정돈이
* 등록 시 불필요 필드 거부를 예방한다. prepareForValidation 에서 호출한다.
*/
private function pruneContentPayload(): void
{
$content = $this->input('content');
if (! is_array($content)) {
return;
}
$messageType = (string) ($content['templateMessageType'] ?? '');
$emphasizeType = (string) ($content['templateEmphasizeType'] ?? '');
if (! in_array($messageType, ['EX', 'MI'], true)) {
unset($content['templateExtra']);
}
if ($emphasizeType !== 'TEXT') {
unset($content['templateTitle'], $content['templateSubtitle']);
}
if ($emphasizeType !== 'IMAGE') {
unset($content['templateImageName'], $content['templateImageUrl']);
}
if ($emphasizeType !== 'ITEM_LIST') {
unset($content['templateItem'], $content['templateItemHighlight']);
}
// 빈 선택 값 제거 — 빈 문자열/빈 배열을 kapi 에 보내면 등록이 거부될 수 있다.
foreach (['templatePreviewMessage', 'templateHeader', 'templateExtra', 'templateTitle', 'templateSubtitle', 'templateImageName', 'templateImageUrl'] as $key) {
if (array_key_exists($key, $content) && trim((string) $content[$key]) === '') {
unset($content[$key]);
}
}
foreach (['buttons', 'quickReplies'] as $group) {
if (array_key_exists($group, $content)) {
$items = is_array($content[$group]) ? array_values(array_filter($content[$group], 'is_array')) : [];
$items = array_map($this->pruneEmptyStrings(...), $items);
if ($items === []) {
unset($content[$group]);
} else {
$content[$group] = $items;
}
}
}
if (array_key_exists('templateItemHighlight', $content)) {
$highlight = is_array($content['templateItemHighlight'])
? $this->pruneEmptyStrings($content['templateItemHighlight'])
: [];
if ($highlight === []) {
unset($content['templateItemHighlight']);
} else {
$content['templateItemHighlight'] = $highlight;
}
}
if (array_key_exists('templateRepresentLink', $content)) {
$link = is_array($content['templateRepresentLink'])
? $this->pruneEmptyStrings($content['templateRepresentLink'])
: [];
if ($link === []) {
unset($content['templateRepresentLink']);
} else {
$content['templateRepresentLink'] = $link;
}
}
if (array_key_exists('templateItem', $content) && is_array($content['templateItem'])) {
$item = $content['templateItem'];
if (isset($item['summary']) && is_array($item['summary'])) {
$summary = $this->pruneEmptyStrings($item['summary']);
if ($summary === []) {
unset($item['summary']);
} else {
$item['summary'] = $summary;
}
}
$content['templateItem'] = $item;
}
$this->merge(['content' => $content]);
}
/**
* 연관 배열에서 빈 문자열 값을 제거합니다.
*
* @param array<string, mixed> $row 대상 배열
* @return array<string, mixed> 빈 문자열이 제거된 배열
*/
private function pruneEmptyStrings(array $row): array
{
return array_filter(
$row,
static fn ($value) => ! (is_string($value) && trim($value) === ''),
);
}
/**
* content 하위 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
private function contentRules(): array
{
return [
'content' => ['sometimes', 'nullable', 'array'],
'content.templateName' => ['required_with:content', 'string', 'max:200'],
'content.templateMessageType' => ['required_with:content', 'in:BA,EX,AD,MI'],
'content.templateEmphasizeType' => ['required_with:content', 'in:NONE,TEXT,IMAGE,ITEM_LIST'],
'content.templateContent' => ['required_with:content', 'string', 'max:1000'],
'content.templatePreviewMessage' => ['nullable', 'string', 'max:40'],
'content.categoryCode' => ['required_with:content', 'string', 'max:20'],
'content.securityFlag' => ['nullable', 'boolean'],
// EX(부가정보형)·MI(복합형)는 부가정보 필수 (A-2)
'content.templateExtra' => ['nullable', 'string', 'required_if:content.templateMessageType,EX,MI'],
// TEXT(강조표기) 필수쌍 — 길이 수치는 문서 미기재라 미강제(kapi 위임)
'content.templateTitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'],
'content.templateSubtitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'],
'content.templateHeader' => ['nullable', 'string', 'max:16'],
// IMAGE(이미지형) 필수쌍 — url 은 업로드 프록시 응답값만 화면이 기입한다
'content.templateImageName' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,IMAGE'],
'content.templateImageUrl' => ['nullable', 'string', 'max:500', 'required_if:content.templateEmphasizeType,IMAGE'],
// ITEM_LIST(아이템리스트) — list 2~10개, 각 title≤6·description≤23 (A-2)
'content.templateItem' => ['nullable', 'array', 'required_if:content.templateEmphasizeType,ITEM_LIST'],
'content.templateItem.list' => ['nullable', 'array', 'min:2', 'max:10', 'required_with:content.templateItem'],
'content.templateItem.list.*.title' => ['required', 'string', 'max:6'],
'content.templateItem.list.*.description' => ['required', 'string', 'max:23'],
'content.templateItem.summary' => ['nullable', 'array'],
'content.templateItem.summary.title' => ['nullable', 'string', 'max:6'],
'content.templateItem.summary.description' => ['nullable', 'string', 'max:14'],
'content.templateItemHighlight' => ['nullable', 'array'],
'content.templateItemHighlight.title' => ['nullable', 'string'],
'content.templateItemHighlight.description' => ['nullable', 'string'],
'content.templateItemHighlight.imageUrl' => ['nullable', 'string', 'max:500'],
'content.templateRepresentLink' => ['nullable', 'array'],
'content.templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'],
'content.templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'],
'content.templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'],
'content.templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'],
'content.buttons' => ['nullable', 'array', 'max:5'],
'content.buttons.*.name' => ['required', 'string', 'max:14'],
'content.buttons.*.linkType' => ['required', 'string', 'in:'.implode(',', self::BUTTON_LINK_TYPES)],
'content.buttons.*.linkMo' => ['nullable', 'string', 'max:500'],
'content.buttons.*.linkPc' => ['nullable', 'string', 'max:500'],
'content.buttons.*.linkAnd' => ['nullable', 'string', 'max:500'],
'content.buttons.*.linkIos' => ['nullable', 'string', 'max:500'],
'content.buttons.*.telNumber' => ['nullable', 'string', 'max:20'],
'content.buttons.*.pluginId' => ['nullable', 'string', 'max:100'],
'content.quickReplies' => ['nullable', 'array', 'max:10'],
'content.quickReplies.*.name' => ['required', 'string'],
'content.quickReplies.*.linkType' => ['required', 'string', 'in:'.implode(',', self::QUICK_REPLY_LINK_TYPES)],
'content.quickReplies.*.linkMo' => ['nullable', 'string', 'max:500'],
'content.quickReplies.*.linkPc' => ['nullable', 'string', 'max:500'],
'content.quickReplies.*.linkAnd' => ['nullable', 'string', 'max:500'],
'content.quickReplies.*.linkIos' => ['nullable', 'string', 'max:500'],
];
}
/**
* 배열 항목별 조건부 필수(linkType 별 링크 필드)와 하이라이트 썸네일 연동 길이를 검증합니다.
*
* Laravel 룰 문법으로 표현하기 어려운 항목별 조건을 after 훅에서 판정한다:
* - WL → linkMo 필수 / AL → linkAnd+linkIos 둘 다 / TN → telNumber / P1~P3 → pluginId (A-3)
* - itemHighlight 는 썸네일(imageUrl) 유무에 따라 title≤30/21 · description≤19/13 (A-2)
*
* @param Validator $validator 검증기
*/
private function validateContentConditionals(Validator $validator): void
{
$content = $this->input('content');
if (! is_array($content)) {
return;
}
foreach (['buttons', 'quickReplies'] as $group) {
$items = $content[$group] ?? null;
if (! is_array($items)) {
continue;
}
foreach ($items as $index => $item) {
if (! is_array($item)) {
continue;
}
$linkType = (string) ($item['linkType'] ?? '');
$path = "content.{$group}.{$index}";
if ($linkType === 'WL' && trim((string) ($item['linkMo'] ?? '')) === '') {
$validator->errors()->add("{$path}.linkMo", __('sirsoft-message_bizppurio::messages.validation.link_mo_required'));
}
if ($linkType === 'AL') {
if (trim((string) ($item['linkAnd'] ?? '')) === '') {
$validator->errors()->add("{$path}.linkAnd", __('sirsoft-message_bizppurio::messages.validation.link_and_required'));
}
if (trim((string) ($item['linkIos'] ?? '')) === '') {
$validator->errors()->add("{$path}.linkIos", __('sirsoft-message_bizppurio::messages.validation.link_ios_required'));
}
}
if ($linkType === 'TN' && trim((string) ($item['telNumber'] ?? '')) === '') {
$validator->errors()->add("{$path}.telNumber", __('sirsoft-message_bizppurio::messages.validation.tel_number_required'));
}
if (in_array($linkType, ['P1', 'P2', 'P3'], true) && trim((string) ($item['pluginId'] ?? '')) === '') {
$validator->errors()->add("{$path}.pluginId", __('sirsoft-message_bizppurio::messages.validation.plugin_id_required'));
}
}
}
$highlight = $content['templateItemHighlight'] ?? null;
if (is_array($highlight)) {
$hasThumbnail = trim((string) ($highlight['imageUrl'] ?? '')) !== '';
$titleMax = $hasThumbnail ? 21 : 30;
$descriptionMax = $hasThumbnail ? 13 : 19;
if (mb_strlen((string) ($highlight['title'] ?? '')) > $titleMax) {
$validator->errors()->add('content.templateItemHighlight.title', __('sirsoft-message_bizppurio::messages.validation.highlight_title_too_long', ['max' => $titleMax]));
}
if (mb_strlen((string) ($highlight['description'] ?? '')) > $descriptionMax) {
$validator->errors()->add('content.templateItemHighlight.description', __('sirsoft-message_bizppurio::messages.validation.highlight_description_too_long', ['max' => $descriptionMax]));
}
}
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use App\Models\NotificationDefinition;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\Concerns\ValidatesTemplateContent;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
/**
* 비즈뿌리오 알림 템플릿 생성 검증 (#597 §3.2).
*
* 알림 1건당 1행(unique)이며, 대상 알림 정의가 실제로 존재해야 한다. content(카카오
* 등록 페이로드)는 선택 — SMS 단독 알림은 알림톡 content 없이 생성할 수 있고,
* 검수 신청 시점에 content 존재를 서비스가 재검한다.
*/
class StoreBizppurioTemplateRequest extends FormRequest
{
use ValidatesTemplateContent;
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 유형과 무관한 content 잔여 필드를 검증 전에 잘라냅니다.
*/
protected function prepareForValidation(): void
{
$this->pruneContentPayload();
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return array_merge([
'notification_type' => [
'required',
'string',
'max:100',
Rule::exists(NotificationDefinition::class, 'type'),
Rule::unique(BizppurioTemplate::class, 'notification_type'),
],
'alimtalk_enabled' => ['sometimes', 'boolean'],
'fallback_sms_enabled' => ['sometimes', 'boolean'],
'sms_only' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
], $this->smsBodyRules(), $this->contentRules());
}
/**
* 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다.
*
* @param Validator $validator 검증기
*/
public function withValidator(Validator $validator): void
{
$validator->after(fn (Validator $v) => $this->validateContentConditionals($v));
}
/**
* 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
public function attributes(): array
{
return array_merge($this->deliveryAttributes(), $this->contentAttributes());
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 알림톡 연동 즉시 저장 검증 (계획서 §6-2, Phase 6).
*
* 코어 편집 모달 전용 칸에서 연결 템플릿·SMS 대체를 바꾸면 즉시 이 요청으로 저장된다.
* template_code 는 nullable — 비어 있으면 연동 해제로 처리한다("연결 안 함" 선택).
*/
class StoreNotificationBindingRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notification_type' => ['required', 'string', 'max:100'],
'template_code' => ['nullable', 'string', 'max:50'],
'template_name' => ['nullable', 'string', 'max:255'],
'fallback_sms_enabled' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use App\Models\NotificationDefinition;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\Concerns\ValidatesTemplateContent;
/**
* 비즈뿌리오 알림 발송 설정 upsert 검증 (#597 §4.1 행 하단 토글).
*
* 알림 설정 탭 행의 대체 SMS·SMS 단독·알림톡 사용 토글이 즉시 저장하는 요청이다.
* 대상 행이 없으면 draft 로 생성(upsert)하므로 notification_type(라우트 파라미터)의
* 알림 정의 존재만 검증한다. 알림톡 content 는 이 경로로 변경할 수 없다(작성 모달 전용).
*/
class UpdateBizppurioDeliveryRequest extends FormRequest
{
// sms_body 규칙·발송 설정 라벨을 Store/Update 와 공유한다. 이 경로만 따로 조립하면
// 세 경로의 검증 강도가 갈리고(약한 쪽이 우회로), 라벨도 빠진다.
use ValidatesTemplateContent;
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 라우트 파라미터(notificationType)를 검증 대상에 병합합니다.
*/
protected function prepareForValidation(): void
{
$this->merge([
'notification_type' => (string) $this->route('notificationType'),
]);
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notification_type' => [
'required',
'string',
'max:100',
Rule::exists(NotificationDefinition::class, 'type'),
],
'alimtalk_enabled' => ['sometimes', 'boolean'],
'fallback_sms_enabled' => ['sometimes', 'boolean'],
'sms_only' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
] + $this->smsBodyRules();
}
/**
* 저장할 발송 설정 필드만 반환합니다 (notification_type 제외).
*
* @return array<string, mixed>
*/
public function deliveryData(): array
{
return collect($this->validated())
->only(['alimtalk_enabled', 'fallback_sms_enabled', 'sms_body', 'sms_only', 'is_active'])
->all();
}
/**
* 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
public function attributes(): array
{
// 공용 deliveryAttributes() 를 그대로 쓴다 — 인라인으로 다시 나열하면 이 클래스만
// notification_type 라벨을 빠뜨린 채 그 필드를 검증하게 된다(실제로 그랬다).
return $this->deliveryAttributes();
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\Concerns\ValidatesTemplateContent;
/**
* 비즈뿌리오 알림 템플릿 수정 검증 (#597 §3.2 — Store 와 대칭 매트릭스).
*
* notification_type 은 행 정체성이라 수정 대상이 아니다(전달돼도 무시 — validated 에
* 포함되지 않는다). content 변경의 상태 가드(draft/rejected 만)는 서비스가 판정한다.
*/
class UpdateBizppurioTemplateRequest extends FormRequest
{
use ValidatesTemplateContent;
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 유형과 무관한 content 잔여 필드를 검증 전에 잘라냅니다 (Store 와 동일 정돈).
*/
protected function prepareForValidation(): void
{
$this->pruneContentPayload();
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return array_merge([
'alimtalk_enabled' => ['sometimes', 'boolean'],
'fallback_sms_enabled' => ['sometimes', 'boolean'],
'sms_only' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
], $this->smsBodyRules(), $this->contentRules());
}
/**
* 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다.
*
* @param Validator $validator 검증기
*/
public function withValidator(Validator $validator): void
{
$validator->after(fn (Validator $v) => $this->validateContentConditionals($v));
}
/**
* 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
*
* @return array<string, string> 필드 경로 → 라벨
*/
public function attributes(): array
{
return array_merge($this->deliveryAttributes(), $this->contentAttributes());
}
}
@@ -25,10 +25,11 @@ use App\Services\SettingsService;
* ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가
* 담당한다(이 리스너는 채널 "노출·판정"만 책임진다).
*
* 알림톡 탭은 코어 기본 목록을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A).
* 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 목록 행 하단에 overlay 로 얹은
* [연결/변경] 버튼 → 우리 연결 모달 → 우리 API(notification-bindings)로 직접 저장하므로,
* 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다.
* 화면 노출은 '비즈뿌리오' 통합 탭 방식이다(#597 §3.3). sms 채널 메타의 hidden_tab 으로
* 개별 탭만 숨기고(채널 토글 카드·발송 축은 유지), alimtalk 메타의 tab_channels ·
* tab_label_key 로 통합 탭의 노출 조건·라벨을 선언한다. 알림톡 템플릿 작성·검수·대체 SMS
* 설정은 코어 목록 행 하단 overlay(row_footer)가 담당하며 저장은 우리 템플릿 API
* (admin/templates)로 직접 수행한다.
*/
class RegisterNotificationChannelsListener implements HookListenerInterface
{
@@ -265,6 +266,13 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
/**
* sms·alimtalk 채널 메타 정의를 반환합니다.
*
* '비즈뿌리오' 통합 탭 메타(#597 §3.3 — 범용 키, 확장명 하드코딩 없음):
* - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로).
* - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다.
* - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리).
* 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다
* (is_test_mode 와 동일 경로).
*
* @return array<int, array<string, mixed>>
*/
private function channelMetas(): array
@@ -278,6 +286,7 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
'source' => self::PLUGIN_IDENTIFIER,
'source_label_key' => self::LANG.'.channels.source_label',
'allow_guest' => true,
'hidden_tab' => true,
],
[
'id' => 'alimtalk',
@@ -287,6 +296,8 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
'source' => self::PLUGIN_IDENTIFIER,
'source_label_key' => self::LANG.'.channels.source_label',
'allow_guest' => true,
'tab_channels' => ['sms', 'alimtalk'],
'tab_label_key' => self::PLUGIN_IDENTIFIER.'.channels.bizppurio_tab',
],
];
}
@@ -29,6 +29,13 @@ use App\Contracts\Extension\HookListenerInterface;
* 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은
* 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를
* 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다.
*
* #597 이후에도 이 시딩은 존치한다. 발송 본문의 SSoT 는 bizppurio_templates 로 옮겨졌지만
* (SmsChannelDriver 는 sms_body, AlimtalkChannelDriver 는 approved_content 를 읽는다),
* 코어·게시판·이커머스의 알림 설정 화면이 여전히 이 template 행을 그리기 때문이다
* (채널 서브탭의 행 제목·수신자 표시, 행이 없으면 "no_template_for_channel" 문구).
* 즉 이 행들은 화면 표시 전용이며 발송 경로는 소비하지 않는다 — 발송이 안 쓴다는 이유로
* 시딩을 지우면 알림 설정 화면의 비즈뿌리오 탭에서 행 정보가 사라진다.
*/
class SeedChannelTemplatesListener implements HookListenerInterface
{
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 모델.
*
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장하고,
* 알림톡 채널 드라이버(Phase 6)가 이 연결을 조회해 발송 대상 템플릿을 결정한다.
*
* @property int $id
* @property string $notification_type
* @property string $channel
* @property string $template_code
* @property string $template_name
* @property bool $fallback_sms_enabled
* @property bool $is_active
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class BizppurioNotificationBinding extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'bizppurio_notification_bindings';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'notification_type',
'channel',
'template_code',
'template_name',
'fallback_sms_enabled',
'is_active',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'channel' => DispatchChannel::class,
'fallback_sms_enabled' => 'boolean',
'is_active' => 'boolean',
];
}
/**
* 활성 연동만 조회하는 스코프.
*
* @param Builder $query
* @return Builder
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석).
*
* @param Builder $query
* @param string $notificationType 코어 notification_definitions.type
* @return Builder
*/
public function scopeByNotificationType(Builder $query, string $notificationType): Builder
{
return $query->where('notification_type', $notificationType);
}
}
@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
/**
* 비즈뿌리오 알림 템플릿 모델 (#597).
*
* 알림 1건(notification_definitions.type)당 1행. 알림톡 템플릿의 카카오 등록
* 페이로드(content)·승인 스냅샷(approved_content)·검수 상태와, 대체 SMS/SMS 단독
* 본문(sms_body)을 저장한다. 발송 드라이버는 이 행만 보고 판정한다(DB = 발송 SSoT).
*
* @property int $id
* @property string $notification_type
* @property bool $alimtalk_enabled
* @property string|null $template_code
* @property string|null $sender_key
* @property array|null $content
* @property array|null $approved_content
* @property BizppurioTemplateStatus $status
* @property array|null $inspection_detail
* @property Carbon|null $requested_at
* @property Carbon|null $approved_at
* @property Carbon|null $last_synced_at
* @property bool $fallback_sms_enabled
* @property array|null $sms_body
* @property bool $sms_only
* @property bool $is_active
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class BizppurioTemplate extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'bizppurio_templates';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'notification_type',
'alimtalk_enabled',
'template_code',
'sender_key',
'content',
'approved_content',
'status',
'inspection_detail',
'requested_at',
'approved_at',
'last_synced_at',
'fallback_sms_enabled',
'sms_body',
'sms_only',
'is_active',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'alimtalk_enabled' => 'boolean',
'content' => 'array',
'approved_content' => 'array',
'status' => BizppurioTemplateStatus::class,
'inspection_detail' => 'array',
'sms_body' => 'array',
'requested_at' => 'datetime',
'approved_at' => 'datetime',
'last_synced_at' => 'datetime',
'fallback_sms_enabled' => 'boolean',
'sms_only' => 'boolean',
'is_active' => 'boolean',
];
}
/**
* 알림 유형으로 조회하는 스코프 (발송 시 행 해석).
*
* @param Builder $query
* @param string $notificationType 코어 notification_definitions.type
* @return Builder
*/
public function scopeByNotificationType(Builder $query, string $notificationType): Builder
{
return $query->where('notification_type', $notificationType);
}
/**
* 수신자 로케일의 SMS 본문을 반환합니다 (#597 §14.3).
*
* sms_body 는 다국어 맵이다. 코어 NotificationContentBehavior::getLocalizedBody() 와
* 동일한 해석 규칙을 쓴다 — 해당 로케일이 없으면 fallback_locale, 그것도 없으면 빈 문자열.
* 알림톡 content 가 단일 언어인 것은 카카오 제약 때문이며 SMS 에는 그 제약이 없다.
*
* @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일)
* @return string 로케일에 맞는 본문 (없으면 빈 문자열)
*/
public function getLocalizedSmsBody(?string $locale = null): string
{
$locale = $locale ?? app()->getLocale();
$body = is_array($this->sms_body) ? $this->sms_body : [];
$resolved = $body[$locale] ?? $body[config('app.fallback_locale', 'ko')] ?? '';
return is_string($resolved) ? $resolved : '';
}
/**
* SMS 본문이 어느 로케일에든 채워져 있는지 판정합니다 (발송 게이트용).
*
* 게이트는 "이 알림에 문자 본문이 설정되어 있는가" 를 묻는다. 특정 수신자의 로케일에
* 본문이 없더라도 fallback 으로 발송되므로, 한 로케일이라도 비어있지 않으면 true 다.
* 로케일별 공백 판정은 발송 시점에 getLocalizedSmsBody() 로 다시 한다.
*
* @return bool 하나 이상의 로케일에 본문이 있으면 true
*/
public function hasSmsBody(): bool
{
foreach (is_array($this->sms_body) ? $this->sms_body : [] as $text) {
if (is_string($text) && trim($text) !== '') {
return true;
}
}
return false;
}
/**
* 알림톡 발송 가능 여부를 판정합니다 (발송 게이트 §3.5).
*
* alimtalk_enabled + is_active + 승인 상태 + 승인 스냅샷 존재를 모두 요구하고,
* sms_only("SMS 단독")가 켜져 있으면 차단한다.
* 승인 취소로 status 가 draft 로 복귀하면 approved_content 가 남아 있어도 즉시 차단된다.
*
* sms_only 는 이 알림에서 알림톡을 쓰지 않겠다는 운영자의 명시 선언이다(§3.5). 화면 라벨이
* "SMS 단독" 인 이상 이 판정에 반영되지 않으면 체크가 아무것도 바꾸지 못한다. 반대로 SMS
* 발송은 sms_only 와 무관하게 sms_body 기준으로만 판정한다(SmsChannelDriver) — sms_only 는
* 알림톡을 끄는 플래그지 SMS 를 켜는 플래그가 아니다.
*
* @return bool 알림톡 발송 가능하면 true
*/
public function isAlimtalkSendable(): bool
{
return $this->alimtalk_enabled
&& $this->is_active
&& ! $this->sms_only
&& $this->status->isApproved()
&& is_array($this->approved_content)
&& $this->approved_content !== [];
}
}
@@ -6,14 +6,14 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers;
use App\Extension\BasePluginServiceProvider;
use Illuminate\Notifications\ChannelManager;
use Plugins\Sirsoft\MessageBizppurio\Console\SyncTemplateStatusCommand;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioTemplateRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
@@ -31,13 +31,14 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider
/**
* Repository 인터페이스 ↔ 구현체 매핑.
*
* Phase 4 에서 발송 이력·이벤트 연동 Repository 를 등록한다.
* - BizppurioDispatchRepository: 발송 이력
* - BizppurioTemplateRepository: 알림 템플릿 라이프사이클(#597 — bindings 대체)
*
* @var array<class-string, class-string>
*/
protected array $repositories = [
BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class,
BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class,
BizppurioTemplateRepositoryInterface::class => BizppurioTemplateRepository::class,
];
/**
@@ -51,7 +52,6 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider
protected array $cacheServices = [
BizppurioTokenService::class,
WebhookReportService::class,
KakaoTemplateContentResolver::class,
];
/**
@@ -60,12 +60,21 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider
* DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을
* 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와
* LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2).
*
* 콘솔 커맨드(bizppurio:sync-template-status)는 plugin.php::getSchedules() 가 30분
* 주기로 스케줄한다(#597 §3.4).
*/
public function register(): void
{
parent::register();
$this->app->scoped(DispatchLinkContext::class);
if ($this->app->runningInConsole()) {
$this->commands([
SyncTemplateStatusCommand::class,
]);
}
}
/**
@@ -1,79 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 구현체.
*/
class BizppurioNotificationBindingRepository implements BizppurioNotificationBindingRepositoryInterface
{
/**
* 알림 유형+채널로 활성 연동을 조회합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null
*/
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding
{
return BizppurioNotificationBinding::query()
->active()
->byNotificationType($notificationType)
->where('channel', $channel)
->first();
}
/**
* 채널의 모든 연동을 조회합니다.
*
* @param string $channel 채널
* @return Collection<int, BizppurioNotificationBinding>
*/
public function allByChannel(string $channel = 'alimtalk'): Collection
{
return BizppurioNotificationBinding::query()
->where('channel', $channel)
->get();
}
/**
* 알림 유형+채널 연동을 생성하거나 갱신합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioNotificationBinding 저장된 연동
*/
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding
{
$binding = BizppurioNotificationBinding::firstOrNew([
'notification_type' => $notificationType,
'channel' => $channel,
]);
$binding->fill($data)->save();
return $binding;
}
/**
* 알림 유형+채널 연동을 삭제합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return void
*/
public function delete(string $notificationType, string $channel = 'alimtalk'): void
{
BizppurioNotificationBinding::query()
->byNotificationType($notificationType)
->where('channel', $channel)
->delete();
}
}
@@ -0,0 +1,240 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
use App\Models\NotificationDefinition;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
/**
* 비즈뿌리오 알림 템플릿 Repository 구현체 (#597).
*/
class BizppurioTemplateRepository implements BizppurioTemplateRepositoryInterface
{
/**
* 관리 화면 목록이 실제로 그리는 컬럼 (목록 컬럼 프루닝 — content/approved_content 등
* 대형 JSON 은 상세 조회 전용이라 목록에서 제외한다).
*
* @var array<int, string>
*/
private const LIST_COLUMNS = [
'id',
'notification_type',
'alimtalk_enabled',
'template_code',
'status',
'requested_at',
'approved_at',
'last_synced_at',
'fallback_sms_enabled',
'sms_only',
'is_active',
];
/**
* PK 로 템플릿을 조회합니다.
*
* @param int $id 템플릿 PK
* @return BizppurioTemplate|null 매칭 행 또는 null
*/
public function find(int $id): ?BizppurioTemplate
{
return BizppurioTemplate::query()->find($id);
}
/**
* 알림 유형으로 템플릿을 조회합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @return BizppurioTemplate|null 매칭 행 또는 null
*/
public function findByType(string $notificationType): ?BizppurioTemplate
{
return BizppurioTemplate::query()
->byNotificationType($notificationType)
->first();
}
/**
* 관리 화면 목록을 페이지네이션 조회합니다 (알림 정의 라벨·소속 조인).
*
* 알림 정의(1:1, type unique)와 LEFT JOIN 해 목록이 그리는 라벨(definition_name)과
* 소속(definition_source)을 함께 싣는다. 1:1 조인이므로 행 부풀림이 없다. 정렬은
* 비고유 컬럼(updated_at) 뒤에 기본키를 덧붙여 전순서를 보장한다.
*
* @param array<string, mixed> $filters status / search
* @param int $page 페이지 번호
* @param int $perPage 페이지 크기
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator
{
$templates = (new BizppurioTemplate)->getTable();
$definitions = (new NotificationDefinition)->getTable();
$query = BizppurioTemplate::query()
->leftJoin($definitions.' as nd', 'nd.type', '=', $templates.'.notification_type')
->select(array_map(
static fn (string $column): string => $templates.'.'.$column,
self::LIST_COLUMNS,
))
->addSelect([
'nd.name as definition_name',
'nd.variables as definition_variables',
'nd.extension_type as definition_extension_type',
'nd.extension_identifier as definition_extension_identifier',
])
// 목록 배지가 "미작성 vs 작성중"을 구분하도록 content 존재 플래그만 계산해 싣는다.
// raw 안의 테이블명에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다.
->selectRaw('('.DB::getTablePrefix().$templates.'.content is not null) as has_content');
if (! empty($filters['status'])) {
$query->where($templates.'.status', (string) $filters['status']);
}
if (! empty($filters['search'])) {
$term = '%'.addcslashes((string) $filters['search'], '%_\\').'%';
// nd.name 은 translatable JSON 컬럼 — raw LIKE 는 유니코드 이스케이프 저장값과
// 비교되어 비ASCII(한글 등) 검색이 항상 0건이 된다. 운영자가 화면에서 보는
// 표시명으로 검색되도록 로케일별 값을 추출해 비교한다 (선례:
// sirsoft-ecommerce ProductInquiryRepository 의 product_name_snapshot 검색).
// 로케일 키는 config 유래(사용자 입력 아님)이고 검색어는 바인딩으로 전달된다.
$locales = config('app.translatable_locales', ['ko', 'en']);
// raw 안의 조인 별칭에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다
// (조인 선언의 'as nd' 는 실제로 '{prefix}nd' 별칭이 된다).
$ndName = DB::getTablePrefix().'nd.name';
$query->where(function ($q) use ($templates, $term, $locales, $ndName) {
$q->where($templates.'.notification_type', 'like', $term);
foreach ($locales as $locale) {
$q->orWhereRaw(
"JSON_UNQUOTE(JSON_EXTRACT({$ndName}, '$.{$locale}')) LIKE ?",
[$term],
);
}
});
}
return $query
->orderByDesc($templates.'.updated_at')
// audit:allow repository-pagination-key-tiebreak reason: 아래 기본키 tiebreak 이 전순서를 보장한다 —
// 조인 모호성 회피를 위해 테이블 한정 식별자(변수 결합)를 쓰므로 룰 정규식이 인식하지 못할 뿐이다
->orderByDesc($templates.'.id')
// audit:allow repository-paginate-column-pruning reason: 위 select()/addSelect() 가 목록 소비 컬럼만
// 명시 조회한다 — paginate 인자의 컬럼 기본값은 기존 select 를 덮지 않는다
->paginate($perPage, page: $page);
}
/**
* 알림 설정 탭 행 표시용 전체 요약을 조회합니다.
*
* 행 수는 알림 정의 수에 묶인 설정성 테이블이라 전량 조회한다(페이지네이션 규정의
* 설정성 테이블 예외). 요약에 불필요한 대형 JSON(content/approved_content)은 제외하되,
* 반려 사유(inspection_detail)는 행 UI 의 [사유 보기]가 소비하므로 포함한다.
*
* @return Collection<int, BizppurioTemplate> 요약 컬럼만 실린 행 컬렉션
*/
public function allSummaries(): Collection
{
return BizppurioTemplate::query()
->select(array_merge(self::LIST_COLUMNS, ['inspection_detail', 'sms_body']))
// content 원본은 요약에 불필요하지만 "작성 여부"(미작성 vs 작성중 배지 분기)는
// 행 UI 가 소비하므로 존재 플래그만 계산해 싣는다.
->selectRaw('(content is not null) as has_content')
->orderBy('notification_type')
->get();
}
/**
* 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별).
*
* @param string $status BizppurioTemplateStatus value
* @return Collection<int, BizppurioTemplate>
*/
public function allByStatus(string $status): Collection
{
return BizppurioTemplate::query()
->where('status', $status)
->orderBy('id')
->get();
}
/**
* 템플릿 행을 생성합니다.
*
* @param array<string, mixed> $data 생성 데이터
* @return BizppurioTemplate 생성된 행
*/
public function create(array $data): BizppurioTemplate
{
return BizppurioTemplate::create($data);
}
/**
* 템플릿 행을 갱신합니다.
*
* @param BizppurioTemplate $template 대상 행
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioTemplate 갱신된 행
*/
public function update(BizppurioTemplate $template, array $data): BizppurioTemplate
{
$template->fill($data)->save();
return $template;
}
/**
* 템플릿 행을 삭제합니다.
*
* @param BizppurioTemplate $template 대상 행
*/
public function delete(BizppurioTemplate $template): void
{
$template->delete();
}
/**
* 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지).
*
* @param string $templateCode 검사할 코드
* @return bool 사용 중이면 true
*/
public function templateCodeExists(string $templateCode): bool
{
return BizppurioTemplate::query()
->where('template_code', $templateCode)
->exists();
}
/**
* 검수 신청을 위해 행을 원자적으로 선점합니다.
*
* 신청 가능 상태(draft/rejected)를 WHERE 조건에 포함한 조건부 UPDATE 라
* 같은 행을 동시에 든 요청 중 정확히 하나만 1행 갱신을 받는다.
*
* @param BizppurioTemplate $template 대상 행
* @return bool 선점 성공 여부
*/
public function claimForInspection(BizppurioTemplate $template): bool
{
$claimed = BizppurioTemplate::query()
->whereKey($template->id)
->whereIn('status', [
BizppurioTemplateStatus::Draft->value,
BizppurioTemplateStatus::Rejected->value,
])
->update(['status' => BizppurioTemplateStatus::Requested->value]) === 1;
if ($claimed) {
$template->refresh();
}
return $claimed;
}
}
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 계약.
*
* 알림톡 탭 연동 CRUD(계획서 §6-2, Phase 6)와 알림톡 발송 시 template_code 해석을
* 담당한다. Phase 4 는 계약과 기본 조회/저장을 제공한다.
*/
interface BizppurioNotificationBindingRepositoryInterface
{
/**
* 알림 유형+채널로 활성 연동을 조회합니다 (발송 시 template_code 해석).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널 (기본 alimtalk)
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null(미연결)
*/
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding;
/**
* 채널의 모든 연동을 조회합니다 (알림톡 탭 목록).
*
* @param string $channel 채널 (기본 alimtalk)
* @return Collection<int, BizppurioNotificationBinding>
*/
public function allByChannel(string $channel = 'alimtalk'): Collection;
/**
* 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled / is_active
* @return BizppurioNotificationBinding 저장된 연동
*/
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding;
/**
* 알림 유형+채널 연동을 삭제합니다 (연동 해제).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return void
*/
public function delete(string $notificationType, string $channel = 'alimtalk'): void;
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
/**
* 비즈뿌리오 알림 템플릿 Repository 인터페이스 (#597).
*/
interface BizppurioTemplateRepositoryInterface
{
/**
* PK 로 템플릿을 조회합니다.
*
* @param int $id 템플릿 PK
* @return BizppurioTemplate|null 매칭 행 또는 null
*/
public function find(int $id): ?BizppurioTemplate;
/**
* 알림 유형으로 템플릿을 조회합니다 (발송 시 행 해석).
*
* @param string $notificationType 코어 notification_definitions.type
* @return BizppurioTemplate|null 매칭 행 또는 null
*/
public function findByType(string $notificationType): ?BizppurioTemplate;
/**
* 관리 화면 목록을 페이지네이션 조회합니다 (알림 정의 라벨·소속 조인).
*
* @param array<string, mixed> $filters status / search
* @param int $page 페이지 번호
* @param int $perPage 페이지 크기
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator;
/**
* 알림 설정 탭 행 표시용 전체 요약을 조회합니다 (notification_type 키 맵 소스).
*
* 행 수는 운영자가 등록한 알림 정의 수에 묶인 설정성 테이블이므로 상한 없이 전량
* 조회한다(페이지네이션 규정의 설정성 테이블 예외). content 등 대형 JSON 컬럼은
* 요약에 필요 없으므로 제외한다(컬럼 프루닝).
*
* @return Collection<int, BizppurioTemplate> 요약 컬럼만 실린 행 컬렉션
*/
public function allSummaries(): Collection;
/**
* 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별).
*
* 대상 행 수는 알림 정의 수에 묶인다(설정성 테이블 예외 — 상한 불요).
*
* @param string $status BizppurioTemplateStatus value
* @return Collection<int, BizppurioTemplate>
*/
public function allByStatus(string $status): Collection;
/**
* 템플릿 행을 생성합니다.
*
* @param array<string, mixed> $data 생성 데이터
* @return BizppurioTemplate 생성된 행
*/
public function create(array $data): BizppurioTemplate;
/**
* 템플릿 행을 갱신합니다.
*
* @param BizppurioTemplate $template 대상 행
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioTemplate 갱신된 행
*/
public function update(BizppurioTemplate $template, array $data): BizppurioTemplate;
/**
* 템플릿 행을 삭제합니다.
*
* @param BizppurioTemplate $template 대상 행
*/
public function delete(BizppurioTemplate $template): void;
/**
* 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지).
*
* @param string $templateCode 검사할 코드
* @return bool 사용 중이면 true
*/
public function templateCodeExists(string $templateCode): bool;
/**
* 검수 신청을 위해 행을 원자적으로 선점합니다.
*
* `status IN (draft, rejected)` 인 행만 조건부 UPDATE 로 requested 로 전이한다.
* 더블 클릭·이중 탭·복수 관리자처럼 같은 draft 상태를 동시에 든 요청 중
* 정확히 하나만 true 를 받는다 — 경합의 패자는 kapi 에 도달하기 전에 걸러진다.
*
* @param BizppurioTemplate $template 대상 행
* @return bool 선점 성공(이 요청이 신청을 진행) 여부
*/
public function claimForInspection(BizppurioTemplate $template): bool;
}
@@ -8,7 +8,6 @@ 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\Str;
@@ -17,40 +16,32 @@ 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\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
/**
* 코어 알림 시스템의 alimtalk 채널 드라이버 (계획서 §6-2·Phase 6).
* 코어 알림 시스템의 alimtalk 채널 드라이버 (#597 §3.5).
*
* ChannelManager::extend('alimtalk', …)(ServiceProvider::boot)로 등록되어, 코어가
* `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Phase 3 까지는
* no-op 스텁이 등록돼 있었고(크래시 방지), 이 드라이버가 그 스텁을 실발송으로 교체한다.
* `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다.
*
* SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 본문·요소의 출처"다:
* - SMS: 코어 알림 템플릿(sms 채널) 본문을 그대로 발송한다.
* - 알림톡: 관리자가 알림톡 탭에서 연결한 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을
* 카카오 상세조회로 가져와 발송한다(B안). 비즈뿌리오 발송 API 는 완성본을 요구하므로
* templatecode 만으로는 안 되고, 카카오에 등록된 templateContent·buttons·quickReplies·
* title·header·item·itemHighlight·representLink 를 발송 형식으로 변환해 채운다.
* 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수).
* 발송 본문·요소의 출처는 bizppurio_templates 의 승인 스냅샷(approved_content)이다 —
* 운영자가 시스템에서 작성해 검수 신청하고 승인된 그 내용 그대로다. 발송 시점의
* 카카오 실시간 조회는 없다(DB 가 유일한 판정 근거, 이슈 #597 요구).
*
* 발송 게이트: 행 존재 + alimtalk_enabled + is_active + status=approved +
* approved_content 존재(BizppurioTemplate::isAlimtalkSendable). 승인 취소로 status 가
* draft 로 복귀하면 스냅샷이 남아 있어도 즉시 차단된다.
*
* 처리 흐름:
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 NotificationSendSkippedException.
* 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 동일 예외.
* 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 동일 예외.
* 5. refkey 생성 → payload 조립(버튼·요소는 extra, 대체발송 ON 시 resend/recontent 병합) →
* 이력 pending → SendMessageJob 위임.
*
* 1~4 단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라
* NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의
* catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌
* "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로
* 기록하는 문제 — 이슈 #28). 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송도
* 트리거되지 않는다 — SMS 대체는 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤
* 비즈뿌리오 측 발송 실패(수신 거부·미가입 등)에만 작동한다(fallback_sms_enabled ON 시).
* 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로 webhook·Job 이 이력에 기록한다(Phase 4).
* 1. 알림 유형(type)으로 템플릿 행 조회 → 발송 게이트 판정. 불충족 시
* NotificationSendSkippedException(코어가 실패로 기록 — 이슈 #28 계약 유지).
* 2. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 3. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper — 등록
* 페이로드와 kapi 상세의 필드명이 동일해 스냅샷을 그대로 공급한다).
* 4. refkey 생성 → payload 조립(버튼·요소는 extra, fallback_sms_enabled ON 시
* resend/recontent 병합 — 대체 본문은 행의 sms_body) → 이력 pending → SendMessageJob 위임.
*/
class AlimtalkChannelDriver
{
@@ -61,24 +52,20 @@ class AlimtalkChannelDriver
public const RECIPIENT_PHONE_KEY = '_recipient_phone';
/**
* @param NotificationTemplateService $templateService SMS 대체발송 본문(코어 alimtalk 템플릿) resolve
* @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용)
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
* @param BizppurioTemplateRepositoryInterface $templates 알림 템플릿 행 조회(발송 게이트·본문 소스)
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
* @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안)
* @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안)
* @param AlimtalkPayloadMapper $payloadMapper 승인 스냅샷 → 발송 형식 변환·치환
* @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용)
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
private readonly NotificationDefinitionService $definitionService,
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly BizppurioTemplateRepositoryInterface $templates,
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
private readonly DispatchLinkContext $linkContext,
private readonly KakaoTemplateContentResolver $kakaoContent,
private readonly AlimtalkPayloadMapper $payloadMapper,
private readonly PluginSettingsService $pluginSettings,
) {}
@@ -119,27 +106,17 @@ class AlimtalkChannelDriver
$type = $notification->getType();
// 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송)
// 1. 템플릿 행 조회 + 발송 게이트 판정 (미승인·비활성·행 없음 → skip).
// 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
// channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
$binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value);
if ($binding === null) {
$template = $this->templates->findByType($type);
if ($template === null || ! $template->isAlimtalkSendable()) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)])
__('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_template_not_approved', ['type' => $this->resolveTypeLabel($type)])
);
}
// 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로,
// templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안).
// 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다.
$kakaoContent = $this->kakaoContent->resolve($binding->template_code);
if ($kakaoContent === null) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)])
);
}
// 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone)
// 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone)
$to = $this->resolvePhone($notifiable, $notification->getData());
if ($to === null) {
throw new NotificationSendSkippedException(
@@ -147,8 +124,8 @@ class AlimtalkChannelDriver
);
}
// 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
$mapped = $this->payloadMapper->map($kakaoContent, $notification->getData());
// 3. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
$mapped = $this->payloadMapper->map((array) $template->approved_content, $notification->getData());
$message = (string) ($mapped['message'] ?? '');
if (trim($message) === '') {
throw new NotificationSendSkippedException(
@@ -156,21 +133,25 @@ class AlimtalkChannelDriver
);
}
// 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합)
// 4. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra, 대체발송 ON 시 SMS resend 병합)
$refkey = $this->generateRefkey();
$payload = $this->payloadBuilder->buildAlimtalk(
$to,
$binding->template_code,
(string) $template->template_code,
$message,
$refkey,
(array) ($mapped['extra'] ?? []),
);
if ($binding->fallback_sms_enabled) {
$payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification));
if ($template->fallback_sms_enabled) {
$payload = $this->withSmsFallback($payload, $this->smsFallbackBody(
$template,
$notification,
BaseNotification::resolveNotifiableLocale($notifiable),
));
}
// 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신.
// 5. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신.
$this->dispatches->create([
'refkey' => $refkey,
'channel' => DispatchChannel::Alimtalk->value,
@@ -194,40 +175,39 @@ class AlimtalkChannelDriver
}
/**
* SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안).
* SMS 대체발송 본문을 행의 sms_body 에서 렌더합니다 (#597 §3.5).
*
* 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로
* 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면
* 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2).
* 대체 SMS 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다.
* `#{var}` 표기를 알림 data 로 치환하며, 본문이 비어 있으면 빈 문자열을 반환하고
* withSmsFallback 이 병합하지 않는다(빈 SMS 방지).
*
* @param string $type 알림 유형
* @param object $notifiable 수신자
* sms_body 는 로케일별 맵이므로 SmsChannelDriver 와 동일하게 수신자 로케일로 렌더한다 —
* 같은 알림의 SMS 단독 발송과 대체발송이 서로 다른 언어로 나가면 안 된다(§14.3).
*
* @param BizppurioTemplate $template 템플릿 행
* @param GenericNotification $notification 발송 대상 알림
* @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일)
* @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열)
*/
private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string
private function smsFallbackBody(object $template, GenericNotification $notification, ?string $locale = null): string
{
$template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value);
if ($template === null || ! $template->is_active) {
$body = trim($template->getLocalizedSmsBody($locale));
if ($body === '') {
return '';
}
$locale = BaseNotification::resolveNotifiableLocale($notifiable);
$rendered = $template->replaceVariables($notification->getData(), $locale);
return (string) ($rendered['body'] ?? '');
return $this->payloadMapper->substituteText($body, $notification->getData());
}
/**
* 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2).
* 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송).
*
* 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은
* 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` +
* `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재)
* 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2).
* 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다.
* `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다(현행 방식 유지 —
* G7 레벨 재발송 없음). 대체 본문이 비어 있으면 빈 SMS 를 보내지 않도록 병합하지 않는다.
*
* @param array<string, mixed> $payload 알림톡 발송 payload
* @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용)
* @param string $renderedBody 치환 완료된 대체 SMS 본문
* @return array<string, mixed> resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로)
*/
private function withSmsFallback(array $payload, string $renderedBody): array
@@ -232,6 +232,21 @@ class AlimtalkPayloadMapper
return $mapped;
}
/**
* 임의 텍스트의 카카오 변수(#{key})를 알림 data 로 치환합니다 (공개 진입점).
*
* 대체 SMS·SMS 단독 본문(bizppurio_templates.sms_body)이 알림톡 본문과 동일한
* `#{var}` 표기를 쓰므로(#597 §3.1), 드라이버가 본문 치환에 재사용한다.
*
* @param string $text 치환 대상(#{key} 포함)
* @param array<string, mixed> $data 변수 치환 소스
* @return string 치환된 문자열
*/
public function substituteText(string $text, array $data): string
{
return $this->substitute($text, $data);
}
/**
* 카카오 변수(#{key})를 알림 data 값으로 치환합니다.
*
@@ -4,137 +4,24 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 알림톡 템플릿 조회 서비스 (Phase 5).
* 알림톡 작성 모달 참조 조회 서비스 (#597).
*
* 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿을
* 실시간 조회한다(목록·상세·카테고리·발신프로필). 등록·수정·삭제·검수·상태변경은 비즈뿌리오
* 콘솔로 위임하며, 이 화면은 목록·상태·내용 조회 + 알림 연결만 담당한다. 템플릿은 DB 에
* 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3).
*
* 이 서비스는 serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK)를 상태 배지로 매핑하는
* 도메인 로직을 담당한다(RDY/ACT 만 알림 연결 가능 상태).
*
* 발신프로필 키(senderKey)는 환경설정(sender_key)에서 가져오며, 미설정 시 조회 자체가
* 불가능하므로 화면은 readiness 로 사전 안내한다(§6-3).
* 알림톡 템플릿 작성 모달이 소비하는 카테고리·발신프로필 kapi 조회를 위임한다.
* 템플릿 목록/상세의 실시간 조회(구 Phase 5)는 DB 기반 라이프사이클
* (BizppurioTemplateService)로 대체되어 제거됐다 — 발송 전 실시간 조회는 없다.
*/
class AlimtalkTemplateService
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 목록 조회 기본 페이지 크기 */
private const DEFAULT_COUNT = 20;
/** kapi 결과코드: 요청한 데이터가 없음(검색 결과 0건 포함, 13.응답코드정의.md) */
private const NOT_FOUND_CODE = '508';
/**
* serviceStatus → 상태 배지 매핑.
*
* key = kapi serviceStatus, value = ['label_key' => lang key, 'variant' => 배지 색].
* variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple).
*
* @var array<string, array{label_key: string, variant: string}>
*/
private const STATUS_BADGES = [
'RDY' => ['label_key' => 'sendable', 'variant' => 'green'],
'ACT' => ['label_key' => 'sendable', 'variant' => 'green'],
'REQ' => ['label_key' => 'inspecting', 'variant' => 'yellow'],
'REJ' => ['label_key' => 'rejected', 'variant' => 'red'],
'REG' => ['label_key' => 'uninspected', 'variant' => 'gray'],
'STP' => ['label_key' => 'stopped', 'variant' => 'dark'],
'BLK' => ['label_key' => 'blocked', 'variant' => 'dark'],
'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'],
];
/**
* @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트
* @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key)
*/
public function __construct(
private readonly BizppurioKakaoApiClient $kakao,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* @param array<string, mixed> $filters status(templateStatus)·keyword·page·count
* @return array{templates: array<int, array<string, mixed>>, pagination: array<string, int>}
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시(결과코드 508 제외)
*/
public function list(array $filters = []): array
{
$params = [
'count' => (int) ($filters['count'] ?? self::DEFAULT_COUNT),
'page' => max(1, (int) ($filters['page'] ?? 1)),
];
if (! empty($filters['status'])) {
$params['templateStatus'] = (string) $filters['status'];
}
if (! empty($filters['keyword'])) {
$params['keyword'] = (string) $filters['keyword'];
}
$response = $this->kakao->getTemplateList($this->senderKey(), $params);
try {
$this->assertSuccess($response);
} catch (BizppurioApiException $e) {
// 508 = "요청한 데이터가 없음"(검색 결과 0건 포함). 카카오는 이 경우를 목록
// 조회 실패로 응답하지만, 실제로는 정상적인 빈 결과이므로 예외로 취급하지 않는다.
if ($e->getResultCode() === self::NOT_FOUND_CODE) {
return [
'templates' => [],
'pagination' => [
'total' => 0,
'total_page' => 1,
'current_page' => $params['page'],
'per_page' => $params['count'],
],
];
}
throw $e;
}
$rows = (array) ($response['data']['list'] ?? $response['data'] ?? []);
return [
'templates' => array_map(fn (array $row) => $this->decorate($row), $rows),
'pagination' => [
'total' => (int) ($response['totalCount'] ?? count($rows)),
'total_page' => (int) ($response['totalPage'] ?? 1),
'current_page' => (int) ($response['currentPage'] ?? $params['page']),
'per_page' => $params['count'],
],
];
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 배지·가능 액션이 부가된 템플릿 상세
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function detail(string $templateCode): array
{
$response = $this->kakao->getTemplateDetail($this->senderKey(), $templateCode);
$this->assertSuccess($response);
return $this->decorate((array) ($response['data'] ?? []));
}
/**
* 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다.
*
@@ -172,81 +59,6 @@ class AlimtalkTemplateService
return array_values((array) ($response['data']['success'] ?? []));
}
/**
* 템플릿 행에 상태 배지를 부가합니다.
*
* serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다.
* RDY/ACT(승인) 상태만 알림 연결 가능하며, 프론트가 배지로 이를 안내한다.
*
* @param array<string, mixed> $row kapi 템플릿 행
* @return array<string, mixed> 배지가 부가된 행
*/
private function decorate(array $row): array
{
$status = (string) ($row['serviceStatus'] ?? $this->deriveStatus($row));
$badge = self::STATUS_BADGES[$status] ?? ['label_key' => 'unknown', 'variant' => 'gray'];
$row['service_status'] = $status;
$row['status_badge'] = [
// 프론트 $t() 가 해석하는 프론트 lang 키 형식(templates.status.*)으로 준다.
// 프론트(en/ko.json)에는 이 키만 존재하며, 백엔드 messages.php 네임스페이스
// (::messages.template.status.*)는 프론트에 없어 원문이 그대로 노출된다.
'label_key' => 'sirsoft-message_bizppurio.templates.status.'.$badge['label_key'],
'variant' => $badge['variant'],
];
return $row;
}
/**
* 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다.
*
* 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를
* 내려주므로, 목록과 동일한 배지 체계로 환원한다.
*
* @param array<string, mixed> $row kapi 템플릿 상세 행
* @return string serviceStatus 코드
*/
private function deriveStatus(array $row): string
{
$inspection = (string) ($row['inspectionStatus'] ?? '');
$status = (string) ($row['status'] ?? '');
$block = (bool) ($row['block'] ?? false);
$dormant = (bool) ($row['dormant'] ?? false);
return match (true) {
$block => 'BLK',
$dormant => 'DMT',
$inspection === 'REQ' => 'REQ',
$inspection === 'REJ' => 'REJ',
$inspection === 'APR' && $status === 'S' => 'STP',
$inspection === 'APR' && $status === 'A' => 'ACT',
$inspection === 'APR' => 'RDY',
default => 'REG',
};
}
/**
* 환경설정에서 발신프로필 키(sender_key)를 조회합니다.
*
* @return string 발신프로필 키
*
* @throws BizppurioApiException 미설정 시
*/
private function senderKey(): string
{
$settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
$senderKey = (string) ($settings['sender_key'] ?? '');
if ($senderKey === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.sender_key_missing'),
);
}
return $senderKey;
}
/**
* kapi 응답이 성공(200)이 아니면 message 를 담아 예외를 던집니다.
*
@@ -91,7 +91,7 @@ class BizppurioKakaoApiClient
/**
* 카카오 관리 API 의 임의 엔드포인트를 호출합니다.
*
* Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는
* 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는
* 자동으로 주입되므로 도메인 파라미터만 전달한다.
*
* @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add')
@@ -105,6 +105,196 @@ class BizppurioKakaoApiClient
return $this->post($path, $params);
}
/**
* 알림톡 템플릿을 등록합니다. (`/v3/kakao/template/add`)
*
* @param array<string, mixed> $params 등록 페이로드 (senderKey/templateCode/templateName 등 부록 A-2)
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function addTemplate(array $params): array
{
return $this->post('/v3/kakao/template/add', $params);
}
/**
* 알림톡 템플릿을 수정합니다. (`/v3/kakao/template/update` — status R + REG/REJ 만 가능)
*
* @param array<string, mixed> $params 수정 페이로드 (add 와 동일 + 선택 newTemplateCode)
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function updateTemplate(array $params): array
{
return $this->post('/v3/kakao/template/update', $params);
}
/**
* 알림톡 템플릿을 삭제합니다. (`/v3/kakao/template/delete` — status R + REG/REJ 만 가능)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function deleteTemplate(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/delete', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 템플릿 코드 중복을 검증합니다. (`/v3/kakao/template/codeCheck`)
*
* 응답 code `200` = 사용 가능, 그 외(504 중복 등) = 사용 불가(부록 A-4).
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 검사할 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function checkTemplateCode(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/codeCheck', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 템플릿 검수를 요청합니다. (`/v3/kakao/template/request`)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @param string $comment 검수자 전달 의견 (≤500)
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function requestInspection(string $senderKey, string $templateCode, string $comment = ''): array
{
$params = [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
];
if ($comment !== '') {
$params['comment'] = $comment;
}
return $this->post('/v3/kakao/template/request', $params);
}
/**
* 템플릿 검수를 취소합니다. (`/v3/kakao/template/cancel_request` — REQ→REG)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function cancelInspection(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/cancel_request', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 템플릿 승인을 취소합니다. (`/v3/kakao/template/cancel_approval` — 승인→등록 복귀)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function cancelApproval(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/cancel_approval', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 휴면(DMT) 템플릿을 해제합니다. (`/v3/kakao/template/release`)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function releaseDormant(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/release', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`)
*
* kapi 유일의 비-JSON(multipart) 엔드포인트다(부록 A-7). 필드는 `image`(파일) +
* bizId/apiKey 이며 senderKey 는 필요 없다. 응답은 `{code, message, image}` 로
* data 봉투가 아니라 최상위 `image` 필드에 업로드된 URL 이 실린다.
*
* @param string $path 업로드할 이미지의 로컬 절대 경로
* @param string $filename 원본 파일명 (확장자 판정용)
* @return array<string, mixed> 응답 배열 (code/message/image)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function uploadTemplateImage(string $path, string $filename): array
{
[$bizId, $apiKey] = $this->credentials();
$stream = fopen($path, 'r');
if ($stream === false) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.image_unreadable'),
);
}
try {
$response = $this->http()
->attach('image', $stream, $filename)
->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [
'bizId' => $bizId,
'apiKey' => $apiKey,
]);
} finally {
if (is_resource($stream)) {
fclose($stream);
}
}
if ($response->failed()) {
throw $this->failureException($response);
}
$result = $response->json();
if (! is_array($result)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.invalid_response'),
httpStatus: $response->status(),
);
}
return $result;
}
/**
* 카카오 관리 API 응답이 성공(200)인지 판정합니다.
*
@@ -0,0 +1,719 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
/**
* 비즈뿌리오 알림 템플릿 라이프사이클 서비스 (#597).
*
* 알림톡 템플릿의 시스템 등록 → 검수 신청 → 승인 후 활성화 흐름을 오케스트레이션한다.
*
* - 등록/수정/삭제: DB 행 관리 + 상태 전이 가드(수정·삭제는 draft/rejected 만).
* - 검수 신청: template_code 자체 채번(codeCheck 검증) → kapi add(최초)/update(재신청)
* → kapi request → status=requested.
* - 검수/승인 취소·휴면 해제: kapi 상태변경 API 위임 후 우리 상태 반영.
* - 상태 동기화: kapi 조회 결과의 serviceStatus 를 BizppurioTemplateStatus 로 환원해
* 반영한다. 승인 전이 시 content 를 approved_content 로 동결(발송 SSoT)하고, 반려 시
* detail 의 comments 를 inspection_detail 로 저장한다.
*
* 발송 이전 실시간 조회는 없다 — 발송 드라이버는 이 서비스가 유지하는 DB 행만 판정한다.
*/
class BizppurioTemplateService
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 자체 채번 template_code 접두사 */
private const CODE_PREFIX = 'g7';
/** codeCheck 충돌 시 세대 증가 재시도 최대 횟수 */
private const CODE_CHECK_MAX_ATTEMPTS = 3;
/** 동기화 배치의 kapi 목록 페이지 크기 */
private const SYNC_LIST_COUNT = 500;
/** 동기화 배치의 kapi 목록 최대 페이지 수 (무한 루프 방지) */
private const SYNC_LIST_MAX_PAGES = 10;
/**
* @param BizppurioTemplateRepositoryInterface $templates 템플릿 행 저장소
* @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트
* @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key)
*/
public function __construct(
private readonly BizppurioTemplateRepositoryInterface $templates,
private readonly BizppurioKakaoApiClient $kakao,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 관리 화면 목록을 페이지네이션 조회합니다.
*
* @param array<string, mixed> $filters status / search
* @param int $page 페이지 번호
* @param int $perPage 페이지 크기
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function list(array $filters, int $page, int $perPage): LengthAwarePaginator
{
return $this->templates->paginateWithDefinitions($filters, $page, $perPage);
}
/**
* 알림 설정 탭 행 표시용 요약 맵을 반환합니다 (notification_type 키).
*
* @return array<string, array<string, mixed>> notification_type → 요약
*/
public function summaryMap(): array
{
return $this->templates->allSummaries()
->keyBy('notification_type')
->map(fn (BizppurioTemplate $template): array => [
'id' => $template->id,
'notification_type' => $template->notification_type,
'alimtalk_enabled' => $template->alimtalk_enabled,
'template_code' => $template->template_code,
'status' => $template->status->value,
'has_content' => (bool) $template->getAttribute('has_content'),
'requested_at' => $template->requested_at?->toIso8601String(),
'approved_at' => $template->approved_at?->toIso8601String(),
'last_synced_at' => $template->last_synced_at?->toIso8601String(),
'inspection_detail' => $template->inspection_detail,
'fallback_sms_enabled' => $template->fallback_sms_enabled,
'sms_body' => $template->sms_body,
// 본문 유무·미리보기는 모델이 판정한다 — 화면이 로케일 맵을 직접 훑으면 발송 게이트
// (hasSmsBody: 공백 제거 후 비교, 폴백은 config('app.fallback_locale'))와 규칙이 갈려
// "화면엔 본문 있음 / 실제로는 미발송" 이 된다. 판정 한 벌만 둔다.
'has_sms_body' => $template->hasSmsBody(),
'sms_body_preview' => $template->getLocalizedSmsBody(),
'sms_only' => $template->sms_only,
'is_active' => $template->is_active,
])
->all();
}
/**
* PK 로 템플릿을 조회합니다.
*
* @param int $id 템플릿 PK
* @return BizppurioTemplate|null 매칭 행 또는 null
*/
public function find(int $id): ?BizppurioTemplate
{
return $this->templates->find($id);
}
/**
* 템플릿 행을 생성합니다 (status=draft).
*
* @param array<string, mixed> $data FormRequest 검증 데이터
* @return BizppurioTemplate 생성된 행
*/
public function create(array $data): BizppurioTemplate
{
$data['status'] = BizppurioTemplateStatus::Draft->value;
return $this->templates->create($data);
}
/**
* 발송 설정(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)을 upsert 합니다 (#597 §4.1).
*
* 알림 설정 탭 행의 토글이 즉시 저장하는 경로다. 대상 행이 없으면 draft 로 생성한다.
* 알림톡 content 는 이 경로로 변경되지 않으므로 상태 가드가 필요 없다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param array<string, mixed> $data 발송 설정 필드 (FormRequest 검증 완료)
* @return BizppurioTemplate 저장된 행
*/
public function upsertDelivery(string $notificationType, array $data): BizppurioTemplate
{
$template = $this->templates->findByType($notificationType);
if ($template === null) {
return $this->templates->create(array_merge($data, [
'notification_type' => $notificationType,
'status' => BizppurioTemplateStatus::Draft->value,
]));
}
return $this->templates->update($template, $data);
}
/**
* 템플릿 행을 갱신합니다.
*
* 카카오 등록 내용(content)은 draft/rejected 상태에서만 실을 수 있다 —
* requested 는 검수취소 먼저, approved 는 승인취소 먼저(§3.2). SMS·활성 플래그
* (alimtalk_enabled/fallback_sms_enabled/sms_body/sms_only/is_active)는 라이프사이클과
* 무관한 발송 설정이므로 상태와 무관하게 수정할 수 있다.
*
* 판정은 content 키의 **존재 여부**만 본다. 값의 동일 여부는 보지 않는다 —
* `content` 는 MySQL json 컬럼이고 DB 가 객체 키를 (길이, 사전순)으로 정규화해
* 저장하므로, 화면이 보낸 순서와 DB 에서 읽은 순서가 구조적으로 일치할 수 없다.
* 동일성 비교는 항상 "변경됨" 으로 떨어져 완화가 성립하지 않았고, 화면도 검수중·승인
* 상태에서는 수정 진입 자체를 막고 있어(3면 row_footer·관리화면 모두 draft‖rejected
* 게이트) 그 완화가 보호하는 경로가 애초에 없었다. 발송 설정만 바꾸는 경로는
* content 키를 싣지 않으므로 이 가드에 걸리지 않는다.
*
* @param BizppurioTemplate $template 대상 행
* @param array<string, mixed> $data FormRequest 검증 데이터
* @return BizppurioTemplate 갱신된 행
*
* @throws BizppurioTemplateStateException content 수정 불가 상태일 때
*/
public function update(BizppurioTemplate $template, array $data): BizppurioTemplate
{
if (array_key_exists('content', $data) && ! $template->status->allowsContentEdit()) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.content_locked',
['status' => $template->status->value],
);
}
return $this->templates->update($template, $data);
}
/**
* 검수를 신청합니다: 채번 → kapi add/update → kapi request → status=requested.
*
* - content 미작성이면 신청 불가.
* - draft/rejected 에서만 신청 가능(requested 는 이미 검수중, approved 는 승인취소 먼저).
* - template_code 가 없으면(최초) 자체 채번 후 kapi add, 있으면(재신청) kapi update.
* add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음
* 시도에서 다시 채번부터 진행된다.
*
* @param BizppurioTemplate $template 대상 행
* @return BizppurioTemplate 갱신된 행 (status=requested)
*
* @throws BizppurioTemplateStateException 신청 불가 상태·content 미작성
* @throws BizppurioApiException kapi 실패 (사유 원문 보존)
*/
public function requestInspection(BizppurioTemplate $template): BizppurioTemplate
{
if (! is_array($template->content) || $template->content === []) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.content_missing',
);
}
if (! $template->status->allowsContentEdit()) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.request_not_allowed',
['status' => $template->status->value],
);
}
// 원자 선점: 같은 draft/rejected 행을 동시에 든 요청(더블 클릭·이중 탭·복수 관리자)
// 중 하나만 통과한다. 화면의 disabled 가드는 리렌더 전 창(~수십 ms)을 못 막으므로
// 중복 kapi 제출 차단의 SSoT 는 이 선점이다 (§6.3 10c).
$originalStatus = $template->status;
if (! $this->templates->claimForInspection($template)) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.request_not_allowed',
['status' => BizppurioTemplateStatus::Requested->value],
);
}
try {
$senderKey = $this->senderKey();
$payload = $template->content;
$payload['senderKey'] = $senderKey;
if ($template->template_code === null) {
$code = $this->generateTemplateCode($senderKey, $template->notification_type);
$payload['templateCode'] = $code;
$this->assertSuccess($this->kakao->addTemplate($payload));
$template = $this->templates->update($template, [
'template_code' => $code,
'sender_key' => $senderKey,
]);
} else {
$payload['templateCode'] = $template->template_code;
$this->assertSuccess($this->kakao->updateTemplate($payload));
}
$this->assertSuccess($this->kakao->requestInspection($senderKey, (string) $template->template_code));
} catch (\Throwable $e) {
// 선점 해제: kapi 미완료 상태로 requested 가 남지 않도록 원래 상태로 복귀.
$this->templates->update($template, ['status' => $originalStatus->value]);
throw $e;
}
return $this->templates->update($template, [
'status' => BizppurioTemplateStatus::Requested->value,
'sender_key' => $senderKey,
'requested_at' => now(),
'last_synced_at' => now(),
'inspection_detail' => null,
]);
}
/**
* 검수 신청을 취소합니다 (kapi cancel_request, REQ→REG).
*
* @param BizppurioTemplate $template 대상 행
* @return BizppurioTemplate 갱신된 행 (status=draft)
*
* @throws BizppurioTemplateStateException 검수중이 아닐 때
* @throws BizppurioApiException kapi 실패
*/
public function cancelRequest(BizppurioTemplate $template): BizppurioTemplate
{
if ($template->status !== BizppurioTemplateStatus::Requested) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.cancel_request_not_allowed',
['status' => $template->status->value],
);
}
$this->assertSuccess($this->kakao->cancelInspection(
$this->rowSenderKey($template),
(string) $template->template_code,
));
return $this->templates->update($template, [
'status' => BizppurioTemplateStatus::Draft->value,
'last_synced_at' => now(),
]);
}
/**
* 승인을 취소합니다 (kapi cancel_approval, 승인→등록 복귀).
*
* approved_content 는 유지하되 status 가 approved 를 벗어나므로 발송 게이트가
* 즉시 차단된다(§3.2 — 운영자 확인 모달이 이 효과를 사전 경고한다).
*
* @param BizppurioTemplate $template 대상 행
* @return BizppurioTemplate 갱신된 행 (status=draft)
*
* @throws BizppurioTemplateStateException 승인 상태가 아닐 때
* @throws BizppurioApiException kapi 실패
*/
public function cancelApproval(BizppurioTemplate $template): BizppurioTemplate
{
if ($template->status !== BizppurioTemplateStatus::Approved) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.cancel_approval_not_allowed',
['status' => $template->status->value],
);
}
$this->assertSuccess($this->kakao->cancelApproval(
$this->rowSenderKey($template),
(string) $template->template_code,
));
return $this->templates->update($template, [
'status' => BizppurioTemplateStatus::Draft->value,
'last_synced_at' => now(),
]);
}
/**
* 휴면(DMT) 템플릿을 해제하고 실제 상태를 재동기화합니다.
*
* @param BizppurioTemplate $template 대상 행
* @return BizppurioTemplate 갱신된 행
*
* @throws BizppurioTemplateStateException 휴면 상태가 아닐 때
* @throws BizppurioApiException kapi 실패
*/
public function releaseDormant(BizppurioTemplate $template): BizppurioTemplate
{
if ($template->status !== BizppurioTemplateStatus::Dormant) {
throw new BizppurioTemplateStateException(
'sirsoft-message_bizppurio::messages.template.release_not_allowed',
['status' => $template->status->value],
);
}
$this->assertSuccess($this->kakao->releaseDormant(
$this->rowSenderKey($template),
(string) $template->template_code,
));
// 해제 후 카카오측 실제 상태(RDY/ACT 등)를 되받아 반영한다.
return $this->sync($template);
}
/**
* 단건 상태를 카카오와 동기화합니다 (수동 [새로고침] / 휴면 해제 후).
*
* 카카오측 코드가 없는(등록 전) 행은 동기화 대상이 아니므로 그대로 반환한다.
*
* @param BizppurioTemplate $template 대상 행
* @return BizppurioTemplate 갱신된 행
*
* @throws BizppurioApiException kapi 실패
*/
public function sync(BizppurioTemplate $template): BizppurioTemplate
{
if ($template->template_code === null) {
return $template;
}
$response = $this->kakao->getTemplateDetail(
$this->rowSenderKey($template),
$template->template_code,
);
$this->assertSuccess($response);
$detail = (array) ($response['data'] ?? []);
$serviceStatus = BizppurioTemplateStatus::serviceStatusFromDetail($detail);
return $this->applyServiceStatus($template, $serviceStatus, $detail);
}
/**
* 검수중(requested) 행 전체를 카카오와 일괄 동기화합니다 (스케줄 커맨드).
*
* requested 행이 없으면 kapi 를 호출하지 않는다. 행별 detail 호출 대신 senderKey 별
* template/list 1회(페이지 순회)로 일괄 대조한다(레이트리밋 보호). 반려로 전이한 행만
* 사유(comments) 확보를 위해 detail 을 추가 호출한다.
*
* @return array{checked: int, transitioned: int} 점검·전이 행 수
*/
public function syncRequested(): array
{
$rows = $this->templates->allByStatus(BizppurioTemplateStatus::Requested->value)
->filter(fn (BizppurioTemplate $row) => $row->template_code !== null);
if ($rows->isEmpty()) {
return ['checked' => 0, 'transitioned' => 0];
}
$transitioned = 0;
// 그룹핑은 try 밖에서 평가되므로 여기서 미리 감싼다 — 스냅샷도 환경설정도 비어 있으면
// rowSenderKey() 가 예외를 던지고, 그대로 두면 30분 배치 전체가 죽는다.
try {
$groups = $rows->groupBy(fn (BizppurioTemplate $row) => $this->rowSenderKey($row));
} catch (BizppurioApiException $e) {
Log::warning('[sirsoft-message_bizppurio] 발신프로필 키를 해석할 수 없어 상태 동기화를 건너뜁니다', [
'result_code' => $e->getResultCode(),
'message' => $e->getMessage(),
]);
return ['checked' => $rows->count(), 'transitioned' => 0];
}
foreach ($groups as $senderKey => $group) {
try {
$statusMap = $this->fetchServiceStatusMap((string) $senderKey);
} catch (BizppurioApiException $e) {
Log::warning('[sirsoft-message_bizppurio] 템플릿 상태 일괄 조회 실패 — 이번 주기 건너뜀', [
'sender_key' => $senderKey,
'result_code' => $e->getResultCode(),
'message' => $e->getMessage(),
]);
continue;
}
foreach ($group as $row) {
$serviceStatus = $statusMap[$row->template_code] ?? null;
if ($serviceStatus === null) {
continue;
}
$before = $row->status;
$detail = null;
// 반려 전이만 사유 원문(comments) 확보를 위해 상세를 추가 조회한다.
if (BizppurioTemplateStatus::tryFromServiceStatus($serviceStatus) === BizppurioTemplateStatus::Rejected) {
$detail = $this->fetchDetailOrNull((string) $senderKey, (string) $row->template_code);
}
$updated = $this->applyServiceStatus($row, $serviceStatus, $detail);
if ($updated->status !== $before) {
$transitioned++;
}
}
}
return ['checked' => $rows->count(), 'transitioned' => $transitioned];
}
/**
* 템플릿 행을 삭제합니다. 카카오측 코드는 삭제 가능 상태일 때만 함께 지웁니다.
*
* kapi delete 는 status R + REG/REJ 에서만 허용된다(부록 A-4). 우리 상태가
* draft/rejected 면 kapi 삭제를 시도하고, 그 외 상태(또는 kapi 삭제 실패)면 카카오측은
* 그대로 두고 DB 행만 삭제한다 — 응답에 사유를 명시해 운영자가 알 수 있게 한다.
*
* @param BizppurioTemplate $template 대상 행
* @return array{kakao_deleted: bool, kakao_skip_reason: string|null} 카카오측 삭제 여부·미삭제 사유
*/
public function delete(BizppurioTemplate $template): array
{
$kakaoDeleted = false;
$skipReason = null;
if ($template->template_code === null) {
$skipReason = 'not_registered';
} elseif (! $template->status->allowsContentEdit()) {
$skipReason = 'state_not_deletable';
} else {
try {
$this->assertSuccess($this->kakao->deleteTemplate(
$this->rowSenderKey($template),
$template->template_code,
));
$kakaoDeleted = true;
} catch (BizppurioApiException $e) {
$skipReason = $e->getMessage();
Log::warning('[sirsoft-message_bizppurio] 카카오측 템플릿 삭제 실패 — DB 행만 삭제', [
'template_code' => $template->template_code,
'result_code' => $e->getResultCode(),
'message' => $e->getMessage(),
]);
}
}
$this->templates->delete($template);
return ['kakao_deleted' => $kakaoDeleted, 'kakao_skip_reason' => $skipReason];
}
/**
* 이미지형 템플릿용 이미지를 업로드하고 URL 을 반환합니다 (업로드 프록시).
*
* @param UploadedFile $file 검증 완료된 업로드 파일
* @return string 카카오가 반환한 이미지 URL (templateImageUrl 에 사용)
*
* @throws BizppurioApiException 업로드 실패
*/
public function uploadImage(UploadedFile $file): string
{
$response = $this->kakao->uploadTemplateImage(
$file->getRealPath(),
$file->getClientOriginalName(),
);
$this->assertSuccess($response);
// 부록 A-7: data 봉투가 아니라 응답 최상위 image 필드에 URL 이 실린다.
$url = (string) ($response['image'] ?? '');
if ($url === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.invalid_response'),
);
}
return $url;
}
/**
* kapi serviceStatus 를 행에 반영합니다 (동기화 공통 경로).
*
* - 승인 전이 시 content 를 approved_content 로 동결하고 approved_at 을 확정한다.
* - 반려 시 상세의 comments 를 inspection_detail 로 저장한다.
* - 알 수 없는 serviceStatus 는 상태를 덮어쓰지 않고 동기화 시각만 남긴다.
*
* @param BizppurioTemplate $template 대상 행
* @param string $serviceStatus kapi serviceStatus
* @param array<string, mixed>|null $detail kapi 상세 (comments 소스, 없으면 null)
* @return BizppurioTemplate 갱신된 행
*/
private function applyServiceStatus(
BizppurioTemplate $template,
string $serviceStatus,
?array $detail = null,
): BizppurioTemplate {
$status = BizppurioTemplateStatus::tryFromServiceStatus($serviceStatus);
$data = ['last_synced_at' => now()];
if ($status !== null) {
$data['status'] = $status->value;
if ($status === BizppurioTemplateStatus::Approved && $template->status !== BizppurioTemplateStatus::Approved) {
$data['approved_content'] = $template->content;
$data['approved_at'] = now();
}
if ($status === BizppurioTemplateStatus::Rejected && is_array($detail) && isset($detail['comments'])) {
$data['inspection_detail'] = (array) $detail['comments'];
}
}
return $this->templates->update($template, $data);
}
/**
* senderKey 의 전체 템플릿 상태 맵(templateCode → serviceStatus)을 조회합니다.
*
* kapi list 를 페이지 순회로 일괄 조회한다(최대 페이지 상한으로 무한 루프 방지).
*
* @param string $senderKey 발신프로필 키
* @return array<string, string> templateCode → serviceStatus
*
* @throws BizppurioApiException 조회 실패
*/
private function fetchServiceStatusMap(string $senderKey): array
{
$map = [];
$page = 1;
do {
$response = $this->kakao->getTemplateList($senderKey, [
'count' => self::SYNC_LIST_COUNT,
'page' => $page,
]);
$this->assertSuccess($response);
$data = (array) ($response['data'] ?? []);
foreach ((array) ($data['list'] ?? []) as $row) {
if (is_array($row) && ! empty($row['templateCode'])) {
$map[(string) $row['templateCode']] = (string) ($row['serviceStatus'] ?? '');
}
}
$totalPage = (int) ($data['totalPage'] ?? 1);
$page++;
} while ($page <= $totalPage && $page <= self::SYNC_LIST_MAX_PAGES);
return $map;
}
/**
* 상세를 조회하되 실패는 null 로 되돌립니다 (반려 사유 확보는 부가 정보).
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed>|null 상세 data 또는 실패 시 null
*/
private function fetchDetailOrNull(string $senderKey, string $templateCode): ?array
{
try {
$response = $this->kakao->getTemplateDetail($senderKey, $templateCode);
$this->assertSuccess($response);
return (array) ($response['data'] ?? []);
} catch (BizppurioApiException $e) {
// 반려 사유(comments)를 못 받으면 화면에는 "반려됐는데 사유가 없음" 만 남는다 —
// 상태 전이 자체는 계속 진행하되, 사유가 비는 원인은 로그로 남긴다.
Log::warning('[sirsoft-message_bizppurio] 템플릿 상세 조회 실패 — 반려 사유를 저장하지 못했습니다', [
'sender_key' => $senderKey,
'template_code' => $templateCode,
'result_code' => $e->getResultCode(),
'message' => $e->getMessage(),
]);
return null;
}
}
/**
* template_code 를 자체 채번합니다 (§3.2).
*
* `g7_{md5(notification_type) 앞 8자}_{세대}` 형식(≤30자, 영문/숫자/언더스코어).
* 우리 DB 와 kapi codeCheck 양쪽에서 미사용일 때만 확정하고, 충돌 시 세대를 올려
* 최대 3회 재시도한다.
*
* @param string $senderKey 발신프로필 키
* @param string $notificationType 알림 유형
* @return string 확정된 템플릿 코드
*
* @throws BizppurioApiException 재시도 소진·codeCheck 호출 실패
*/
private function generateTemplateCode(string $senderKey, string $notificationType): string
{
$base = self::CODE_PREFIX.'_'.substr(md5($notificationType), 0, 8);
for ($generation = 1; $generation <= self::CODE_CHECK_MAX_ATTEMPTS; $generation++) {
$code = $base.'_'.$generation;
if ($this->templates->templateCodeExists($code)) {
continue;
}
// codeCheck: code 200 = 사용 가능, 504(중복) 등 = 불가 → 세대 증가 재시도(A-4).
$response = $this->kakao->checkTemplateCode($senderKey, $code);
if ($this->kakao->isSuccess($response)) {
return $code;
}
}
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.template.code_generation_failed'),
);
}
/**
* 행의 발신프로필 키를 반환합니다 (스냅샷 우선, 없으면 현재 설정).
*
* @param BizppurioTemplate $template 대상 행
* @return string 발신프로필 키
*
* @throws BizppurioApiException 스냅샷·설정 모두 없을 때
*/
private function rowSenderKey(BizppurioTemplate $template): string
{
$snapshot = trim((string) $template->sender_key);
return $snapshot !== '' ? $snapshot : $this->senderKey();
}
/**
* 환경설정에서 발신프로필 키(sender_key)를 조회합니다.
*
* @return string 발신프로필 키
*
* @throws BizppurioApiException 미설정 시
*/
private function senderKey(): string
{
$senderKey = trim((string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', ''));
if ($senderKey === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.sender_key_missing'),
);
}
return $senderKey;
}
/**
* kapi 응답이 성공(200)이 아니면 message 원문을 담아 예외를 던집니다.
*
* @param array<string, mixed> $response kapi 응답
*
* @throws BizppurioApiException 실패 코드 시
*/
private function assertSuccess(array $response): void
{
if ($this->kakao->isSuccess($response)) {
return;
}
$message = (string) ($response['message'] ?? '');
$code = (string) ($response['code'] ?? '');
throw new BizppurioApiException(
$message !== ''
? $message
: __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
resultCode: $code !== '' ? $code : null,
);
}
}
@@ -1,193 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 카카오 승인 템플릿 내용(본문·버튼·요소)을 발송용으로 조회·캐시하는 서비스 (B안 5-1).
*
* 발송 시 알림톡 payload 는 카카오에 등록된 승인 템플릿의 실제 내용(templateContent·
* buttons·quickReplies·templateTitle·templateHeader·templateItem·templateItemHighlight·
* templateRepresentLink)으로 채워야 한다(비즈뿌리오 발송 API 가 완성본을 요구 — templatecode
* 는 식별용일 뿐 내용을 대신 채워주지 않는다). 이 서비스는 그 원천을 카카오 상세조회
* (AlimtalkTemplateService::detail)로 가져오되, template_code 단위로 캐시해 rate limit
* (1000회/합산)을 억제한다.
*
* 캐시 정책:
* - TTL 은 환경설정 template_cache_ttl(기본 3600초=1시간). 만료되면 다음 조회에서 자동
* 재조회되므로 카카오 템플릿 변경이 사람 개입 없이 최대 TTL 만큼 지연돼 반영된다.
* - TTL=0 이면 캐시를 우회하고 매 조회마다 실시간 조회한다(항상 최신 — 소량 발송·최신성
* 우선 사이트용).
* - 관리자가 카카오에서 템플릿을 방금 바꿔 즉시 반영이 필요하면 clear() 로 수동 무효화한다.
*
* 조회 실패(고아 template_code·카카오 장애·rate limit)는 예외를 삼키고 null 을 반환한다.
* 호출측(AlimtalkChannelDriver)이 null 을 받으면 알림톡 발송을 skip 하고 로그만 남긴다
* (발송이 접수되지 않으므로 SMS 대체발송은 트리거되지 않는다).
*/
class KakaoTemplateContentResolver
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 템플릿 내용 캐시 키 접두사 (확장 도메인 캐시 접두사가 추가로 적용됨) */
private const CACHE_KEY_PREFIX = 'bizppurio:tpl_content:';
/** 템플릿 내용 캐시 시간 기본값(분) — 1시간 */
private const DEFAULT_CACHE_MINUTES = 60;
/** rate limit(429/5002) 시 재시도 횟수 — 조회 폭주 완화(1회만, 무한 방지) */
private const RATE_LIMIT_RETRIES = 1;
/** rate limit 재시도 전 대기(마이크로초) — 0.2초 */
private const RATE_LIMIT_RETRY_WAIT_US = 200000;
/**
* @param AlimtalkTemplateService $templates 카카오 상세조회 위임
* @param CacheInterface $cache 확장 도메인 캐시(contextual binding 주입)
* @param PluginSettingsService $pluginSettings 캐시 TTL 설정 조회
*/
public function __construct(
private readonly AlimtalkTemplateService $templates,
private readonly CacheInterface $cache,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 카카오 승인 템플릿의 발송용 내용을 조회합니다.
*
* TTL>0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회).
* 조회 실패는 null 을 반환한다.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null
*/
public function resolve(string $templateCode): ?array
{
$ttl = $this->cacheTtl();
// TTL=0 → 캐시 우회, 매번 실시간 조회.
if ($ttl <= 0) {
return $this->fetch($templateCode);
}
$cacheKey = self::CACHE_KEY_PREFIX.$templateCode;
// 캐시 히트면 그대로 반환. 미스면 조회해 캐시.
$cached = $this->cache->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$content = $this->fetch($templateCode);
// 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록.
if ($content !== null) {
$this->cache->put($cacheKey, $content, $ttl);
}
return $content;
}
/**
* 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화).
*
* @param string $templateCode 카카오 템플릿 코드
*/
public function clear(string $templateCode): void
{
$this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode);
}
/**
* 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화).
*
* 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다.
* 다음 발송에서 최신 내용으로 재조회된다.
*
* @param array<int, string> $templateCodes 카카오 템플릿 코드 목록
* @return int 비운 캐시 키 수(중복 제거 후)
*/
public function clearMany(array $templateCodes): int
{
$codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== '')));
foreach ($codes as $code) {
$this->clear((string) $code);
}
return count($codes);
}
/**
* 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그).
*
* rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회
* 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip
* 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 상세 또는 실패 시 null
*/
private function fetch(string $templateCode): ?array
{
for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) {
try {
return $this->templates->detail($templateCode);
} catch (BizppurioApiException $e) {
// rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도.
if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) {
usleep(self::RATE_LIMIT_RETRY_WAIT_US);
continue;
}
Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [
'template_code' => $templateCode,
'result_code' => $e->getResultCode(),
'http_status' => $e->getHttpStatus(),
'message' => $e->getMessage(),
]);
return null;
}
}
return null;
}
/**
* 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다.
*
* @param BizppurioApiException $e 조회 실패 예외
* @return bool rate limit 이면 true
*/
private function isRateLimited(BizppurioApiException $e): bool
{
return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002';
}
/**
* 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔).
*
* 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는
* 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다.
*
* @return int TTL(초)
*/
private function cacheTtl(): int
{
$minutes = (int) $this->pluginSettings->get(
self::PLUGIN_IDENTIFIER,
'template_cache_minutes',
self::DEFAULT_CACHE_MINUTES,
);
return max(0, $minutes) * 60;
}
}
@@ -1,239 +0,0 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 이벤트↔알림톡 템플릿 연동(binding) 서비스 (계획서 §6-2, Phase 6 재설계 A).
*
* 알림 설정 알림톡 탭은 코어 기본 목록을 그대로 사용한다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체는
* 코어 목록 행 하단에 얹은 [연결/변경] 버튼 → 우리 연결 모달에서 입력하고, 우리 API
* (POST notification-bindings)로 이 서비스의 bind/unbind 를 직접 호출해 저장된다
* (코어 저장 버튼과 무관).
*
* 이 서비스는 (1) 연결 모달이 소비하는 조회 — 승인 템플릿 드롭다운(approvedTemplates)
* 과 현재 연동 맵(all) — 및 (2) 연결 모달 저장이 호출하는 bind/unbind 를 제공한다.
*
* 연결 대상 템플릿 드롭다운은 "발송 가능(승인) 상태" 템플릿만 노출한다 — 미승인 템플릿에
* 연동해도 발송이 거부되기 때문. 승인 판정은 AlimtalkTemplateService 의 배지 매핑(RDY/ACT
* = 발송가능)을 재사용한다.
*/
class NotificationBindingService
{
/** 연동 대상 채널 (1차 알림톡 고정) */
private const CHANNEL = 'alimtalk';
/** 발송 가능(연결 허용) 카카오 템플릿 상태 — RDY(발송전)·ACT(정상) */
private const SENDABLE_STATUSES = ['RDY', 'ACT'];
/**
* @param BizppurioNotificationBindingRepositoryInterface $bindings 연동 조회/저장
* @param AlimtalkTemplateService $templates 카카오 승인 템플릿 조회
* @param KakaoTemplateContentResolver $kakaoContent 발송용 템플릿 내용 캐시(수동 초기화 위임)
*/
public function __construct(
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly AlimtalkTemplateService $templates,
private readonly KakaoTemplateContentResolver $kakaoContent,
) {}
/**
* 알림톡 채널의 모든 연동을 notification_type → 연동 정보 맵으로 반환합니다.
*
* 코어 편집 모달 전용 칸이 편집 중인 알림의 기존 연동(연결 템플릿·SMS 대체)을 프리필하는
* 데 쓴다. 코어 목록은 코어가 렌더하므로(⚑⚑ 결정 A) 알림 정의와 조인하지 않고 binding
* 만 내려준다. 편집 모달은 def.type 으로 이 맵을 조회한다.
*
* $withAvailability=true 이면(알림톡 탭 진입 표시용) 카카오 승인 목록을 1회 조회해 각 연동에
* is_unavailable(연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가) 플래그를 부여한다.
* 저장 응답(store)처럼 프리필만 필요한 경로는 기본값(false)으로 호출해 카카오 조회를 생략한다.
*
* @param bool $withAvailability 카카오 승인 목록과 대조해 소실 여부를 부여할지 (표시용)
* @return array<string, array<string, mixed>> notification_type 키의 연동 맵
*/
public function all(bool $withAvailability = false): array
{
// 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명
// 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로
// 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준).
$sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null;
return $this->bindings->allByChannel(self::CHANNEL)
->keyBy('notification_type')
->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) {
$info = [
'notification_type' => $binding->notification_type,
'template_code' => $binding->template_code,
'template_name' => $binding->template_name,
'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled,
];
// 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략).
if ($sendableCodes !== null) {
$info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true);
}
return $info;
})
->all();
}
/**
* 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다.
*
* approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실
* 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다.
*
* @return array<int, string>|null 승인 template_code 목록(성공) 또는 null(조회 실패)
*/
private function sendableTemplateCodesOrNull(): ?array
{
try {
return array_column($this->approvedTemplates(), 'template_code');
} catch (BizppurioApiException) {
return null;
}
}
/**
* 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운).
*
* 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에
* 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다.
*
* @return array<int, array{template_code: string, template_name: string}>
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function approvedTemplates(): array
{
// 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를
// 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다.
$result = $this->templates->list(['count' => 100]);
return collect($result['templates'] ?? [])
->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true))
->map(fn (array $row) => [
'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''),
'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''),
])
->filter(fn (array $row) => $row['template_code'] !== '')
->values()
->all();
}
/**
* 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장).
*
* 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만
* 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀).
* 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로
* 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled
* @return BizppurioNotificationBinding 저장된 연동
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
public function bind(string $notificationType, array $data): BizppurioNotificationBinding
{
$templateCode = (string) $data['template_code'];
$this->assertSendable($templateCode);
return $this->bindings->upsert($notificationType, self::CHANNEL, [
'template_code' => $templateCode,
'template_name' => (string) $data['template_name'],
'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false),
'is_active' => true,
]);
}
/**
* 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다.
*
* @param string $templateCode 검증할 카카오 템플릿 코드
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
private function assertSendable(string $templateCode): void
{
$sendableCodes = array_column($this->approvedTemplates(), 'template_code');
if (! in_array($templateCode, $sendableCodes, true)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]),
);
}
}
/**
* 알림의 알림톡 연동을 해제(삭제)합니다.
*
* @param string $notificationType 코어 notification_definitions.type
*/
public function unbind(string $notificationType): void
{
$this->bindings->delete($notificationType, self::CHANNEL);
}
/**
* 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고
* 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서
* 최신 내용으로 재조회된다.
*
* @return int 초기화한 캐시 키 수(연결된 고유 template_code 수)
*/
public function clearTemplateContentCache(): int
{
$codes = $this->bindings->allByChannel(self::CHANNEL)
->pluck('template_code')
->all();
return $this->kakaoContent->clearMany($codes);
}
/**
* 연결 모달이 넘긴 값으로 연동을 반영합니다.
*
* 우리 연결 저장 API(NotificationBindingController::store → POST notification-bindings)에서
* 호출된다. 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제"
* 규칙 덕분에 연결 모달에서 드롭다운을 "연결 안 함"으로 바꾸고 저장하면 저장 한 번으로
* 해제까지 처리된다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제)
* @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용)
* @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생)
*/
public function applyFromTemplateSave(
string $notificationType,
?string $templateCode,
?string $templateName,
bool $fallbackSmsEnabled,
): void {
$code = trim((string) $templateCode);
if ($code === '') {
$this->unbind($notificationType);
return;
}
$this->bind($notificationType, [
'template_code' => $code,
'template_name' => trim((string) $templateName),
'fallback_sms_enabled' => $fallbackSmsEnabled,
]);
}
}
@@ -8,7 +8,6 @@ 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\Str;
@@ -18,30 +17,30 @@ 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\BizppurioTemplateRepositoryInterface;
/**
* 코어 알림 시스템의 sms 채널 드라이버.
* 코어 알림 시스템의 sms 채널 드라이버 (#597 §3.5).
*
* ChannelManager::extend('sms', …)(ServiceProvider::boot)로 등록되어, 코어가
* `via()` 에서 'sms' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Laravel 채널 계약
* (`send($notifiable, Notification $notification)`)을 구현한다.
*
* 처리 흐름(계획서 Phase 3 ②):
* 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면
* NotificationSendSkippedException(Phase 6 에서 3영역 기본 body 시드 예정 — A안 확정).
* 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다 — 비즈뿌리오
* 탭에서 운영자가 알림별로 입력한 본문(#{var} 치환)이 그대로 발송된다. sms_body 는
* 로케일별 맵이며 수신자 로케일로 렌더한다(알림톡 content 의 단일 언어 제약은 카카오
* 승인 규칙 때문이고 SMS 에는 적용되지 않는다 — §14.3).
*
* 발송 게이트: 행 존재 + is_active + 어느 로케일에든 본문 있음. sms_only 여부와 무관하다 —
* sms 채널이 켜진 알림이면 발송하며, sms_only 는 화면 표시·알림톡 게이트용 플래그다.
*
* 처리 흐름:
* 1. 템플릿 행 조회 → 게이트 판정. 불충족 시 NotificationSendSkippedException
* (코어 NotificationDispatcher 의 catch 가 실패로 기록 — 이슈 #28 계약 유지).
* 2. 전화번호 해석: 회원=Notifiable->mobile, 비회원=알림 data 의 _recipient_phone
* (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1).
* 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 →
* 3. sms_body 의 #{var} 치환 → refkey 생성 → 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 위임까지 담당한다.
*/
class SmsChannelDriver
{
@@ -52,17 +51,19 @@ class SmsChannelDriver
public const RECIPIENT_PHONE_KEY = '_recipient_phone';
/**
* @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve
* @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용)
* @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지·LMS 제목용)
* @param BizppurioTemplateRepositoryInterface $templates 알림 템플릿 행 조회(발송 게이트·본문 소스)
* @param AlimtalkPayloadMapper $payloadMapper sms_body 의 #{var} 치환
* @param SmsTypeResolver $typeResolver SMS/LMS byte 판별
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4)
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
* @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용)
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
private readonly NotificationDefinitionService $definitionService,
private readonly BizppurioTemplateRepositoryInterface $templates,
private readonly AlimtalkPayloadMapper $payloadMapper,
private readonly SmsTypeResolver $typeResolver,
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
@@ -71,17 +72,21 @@ class SmsChannelDriver
) {}
/**
* 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용).
* 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지·LMS 제목용).
*
* 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백).
*
* LMS 제목으로 쓸 때는 수신자 로케일을 넘긴다 — 인자를 생략하면 앱 로케일이 적용되어
* 본문과 제목의 언어가 갈린다. 운영자에게 보이는 스킵 예외 메시지는 앱 로케일이 맞다.
*
* @param string $type 알림 유형 코드값 (welcome 등)
* @param string|null $locale 렌더 로케일 (null 이면 현재 앱 로케일)
* @return string 사람이 읽는 이름 또는 코드값
*/
private function resolveTypeLabel(string $type): string
private function resolveTypeLabel(string $type, ?string $locale = null): string
{
try {
$label = $this->definitionService->resolve($type)?->getLocalizedName();
$label = $this->definitionService->resolve($type)?->getLocalizedName($locale);
return $label !== null && $label !== '' ? $label : $type;
} catch (\Throwable $e) {
@@ -106,11 +111,11 @@ class SmsChannelDriver
$type = $notification->getType();
// 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드)
// 1. 템플릿 행 조회 + 게이트 판정 (행 없음·비활성·본문 없음 → skip).
// 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
// channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
$template = $this->templateService->resolve($type, DispatchChannel::Sms->value);
if ($template === null || ! $template->is_active) {
$template = $this->templates->findByType($type);
if ($template === null || ! $template->is_active || ! $template->hasSmsBody()) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.sms_template_missing', ['type' => $this->resolveTypeLabel($type)])
);
@@ -124,22 +129,27 @@ class SmsChannelDriver
);
}
// 3. 본문 렌더 (변수 치환)
// 3. 본문 렌더 — 수신자 로케일의 sms_body 를 골라 #{var} 치환(알림톡 본문과 동일 규칙).
// 로케일 해석은 코어와 같은 규약(BaseNotification::resolveNotifiableLocale)을 쓴다.
$locale = BaseNotification::resolveNotifiableLocale($notifiable);
$rendered = $template->replaceVariables($notification->getData(), $locale);
$message = (string) ($rendered['body'] ?? '');
$message = $this->payloadMapper->substituteText(
trim($template->getLocalizedSmsBody($locale)),
$notification->getData()
);
if (trim($message) === '') {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)])
);
}
// 4. refkey 생성 → SMS/LMS 판별 → payload 조립
// 4. refkey 생성 → SMS/LMS 판별 → payload 조립.
// LMS 제목은 알림 정의 이름이며, 본문과 같은 수신자 로케일로 렌더한다 — 본문만
// 로케일을 따르고 제목이 앱 로케일이면 한 통 안에서 언어가 갈린다.
$refkey = $this->generateRefkey();
$channel = $this->typeResolver->resolve($message);
$payload = $channel === DispatchChannel::Lms
? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? ''))
? $this->payloadBuilder->buildLms($to, $message, $refkey, $this->resolveTypeLabel($type, $locale))
: $this->payloadBuilder->buildSms($to, $message, $refkey);
// 5. 발송 이력 pending 생성(Phase 4) → Job 위임. Job 이 refkey 로 조회해 sent/failed 갱신.
@@ -5,8 +5,8 @@ use App\Http\Middleware\RefreshTokenExpiration;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Route;
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController;
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController;
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController;
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController;
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\TokenCheckController;
use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController;
@@ -74,50 +74,49 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
/*
|----------------------------------------------------------------------
| 알림톡 템플릿 조회 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임
| 알림톡 작성 모달 참조 조회 (#597) — 발신프로필·카테고리
|----------------------------------------------------------------------
|
| 조회 전용(list/detail/categories/profiles) = messaging.view.
| 템플릿 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임한다(이 화면은 목록·상태·
| 내용 조회 + 알림 연결만 담당). 템플릿은 DB 저장 없이 매 요청 실시간 조회하며, 설정
| 페이지 알림톡 템플릿 탭이 소비한다.
| 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비한다.
| 실시간 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클로 대체되어 제거됐다.
*/
Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () {
// 발송 템플릿 내용 캐시 초기화(수동 갱신) — 카카오에서 템플릿을 방금 바꿔 즉시 반영이
// 필요할 때 관리자가 캐시를 비운다. 쓰기 동작이므로 messaging.manage 권한. 구체 경로를
// 먼저 두어 GET /{templateCode} 와 충돌하지 않게 한다.
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
Route::post('/cache/clear', [AlimtalkTemplateController::class, 'clearCache'])->name('cache.clear');
});
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index');
Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories');
Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles');
Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show');
});
});
/*
|----------------------------------------------------------------------
| 알림↔알림톡 템플릿 연동 (Phase 6 재설계) — 코어 편집 모달 전용 칸이 소비
| 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §3.2)
|----------------------------------------------------------------------
|
| 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체
| 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이 API
| 로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼 무관 →
| 코어 템플릿 무오염).
| 시스템 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화.
| 발송 판정은 DB(bizppurio_templates)가 유일한 근거이며, 카카오 상태 정합은
| 스케줄러(bizppurio:sync-template-status)와 수동 sync 가 유지한다.
|
| 조회(index/approved-templates) = messaging.view / 저장(store) = messaging.manage.
| 조회(index/map/show) = messaging.view / 그 외 변경 = messaging.manage.
| 구체 경로(map/image)를 {id} 보다 먼저 두어 라우트 충돌을 막는다.
*/
Route::prefix('notification-bindings')->name('notification-bindings.')->group(function () {
Route::prefix('templates')->name('templates.')->group(function () {
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
Route::get('/', [NotificationBindingController::class, 'index'])->name('index');
Route::get('/approved-templates', [NotificationBindingController::class, 'approvedTemplates'])->name('approved-templates');
Route::get('/', [BizppurioTemplateController::class, 'index'])->name('index');
Route::get('/map', [BizppurioTemplateController::class, 'map'])->name('map');
Route::get('/{id}', [BizppurioTemplateController::class, 'show'])->whereNumber('id')->name('show');
});
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
Route::post('/', [NotificationBindingController::class, 'store'])->name('store');
Route::post('/', [BizppurioTemplateController::class, 'store'])->name('store');
Route::post('/image', [BizppurioTemplateController::class, 'uploadImage'])->name('image');
Route::put('/delivery/{notificationType}', [BizppurioTemplateController::class, 'upsertDelivery'])->name('delivery');
Route::put('/{id}', [BizppurioTemplateController::class, 'update'])->whereNumber('id')->name('update');
Route::post('/{id}/request', [BizppurioTemplateController::class, 'requestInspection'])->whereNumber('id')->name('request');
Route::post('/{id}/cancel-request', [BizppurioTemplateController::class, 'cancelRequest'])->whereNumber('id')->name('cancel-request');
Route::post('/{id}/cancel-approval', [BizppurioTemplateController::class, 'cancelApproval'])->whereNumber('id')->name('cancel-approval');
Route::post('/{id}/release', [BizppurioTemplateController::class, 'release'])->whereNumber('id')->name('release');
Route::post('/{id}/sync', [BizppurioTemplateController::class, 'sync'])->whereNumber('id')->name('sync');
Route::delete('/{id}', [BizppurioTemplateController::class, 'destroy'])->whereNumber('id')->name('destroy');
});
});
@@ -9,16 +9,17 @@ use Mockery;
use Mockery\MockInterface;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 알림톡 템플릿 조회 컨트롤러 Feature 테스트 (조회 전용).
* 알림톡 작성 모달 참조 조회 컨트롤러 Feature 테스트 (#597).
*
* 라우트 → 컨트롤러 → 서비스 경계를 검증한다. kapi 실제 호출 로직은
* AlimtalkTemplateServiceTest 가 검증하므로, 여기서는 AlimtalkTemplateService 를
* 컨테이너에 mock 으로 바인딩해 조회 권한 경계(view)·응답 봉투·kapi 실패(예외)의 422
* 전파·등록 라우트 제거(405)를 격리 검증한다.
* 컨테이너에 mock 으로 바인딩해 조회 권한 경계(view)·응답 봉투·kapi 실패(예외)의
* 422 전파를 격리 검증한다. 실시간 목록/상세 화면(구 Phase 5)은 DB 기반
* 라이프사이클(BizppurioTemplateController)로 대체되어 제거됐다 — 남은 라우트는
* categories/profiles 둘뿐이다.
*
* @since 1.0.0
*/
@@ -88,169 +89,89 @@ class AlimtalkTemplateControllerTest extends PluginTestCase
}
/**
* @scenario auth=view,service=ok
*
* @effects list_returns_templates_with_status_badge
* view 권한으로 카테고리 전체를 조회한다 (data.categories 봉투).
*/
public function test_view권한으로_목록을_조회한다(): void
public function test_view권한으로_카테고리를_조회한다(): void
{
$this->mockService()->shouldReceive('list')->once()->andReturn([
'templates' => [
['templateCode' => 'TW_1', 'templateName' => '주문완료', 'status_badge' => ['variant' => 'green']],
],
'pagination' => ['total' => 1, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20],
$this->mockService()->shouldReceive('categories')->once()->andReturn([
['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'],
]);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE);
->getJson(self::BASE.'/categories');
$response->assertStatus(200);
$response->assertJsonPath('data.templates.0.templateCode', 'TW_1');
$response->assertJsonPath('data.templates.0.status_badge.variant', 'green');
$response->assertJsonPath('data.categories.0.code', '001001');
$response->assertJsonPath('data.categories.0.groupName', '회원');
}
/**
* @scenario auth=guest
*
* @effects list_requires_authentication_returns_401
* view 권한으로 발신프로필 목록을 조회한다 (data.profiles 봉투).
*/
public function test_view권한으로_발신프로필을_조회한다(): void
{
$this->mockService()->shouldReceive('senderProfiles')->once()->andReturn([
['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'],
]);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE.'/profiles');
$response->assertStatus(200);
$response->assertJsonPath('data.profiles.0.senderKey', 'SK_40');
$response->assertJsonPath('data.profiles.0.name', '테스트채널');
}
/**
* 비인증 요청은 401 이다.
*/
public function test_비인증은_401(): void
{
$this->getJson(self::BASE)->assertStatus(401);
$this->getJson(self::BASE.'/categories')->assertStatus(401);
$this->getJson(self::BASE.'/profiles')->assertStatus(401);
}
/**
* @scenario auth=none_of_required,action=index
*
* @effects list_requires_view_permission_returns_403
* view 권한이 없으면 두 라우트 모두 403 이다.
*/
public function test_view권한_없으면_목록조회_403(): void
public function test_view권한_없으면_403(): void
{
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.other']))
->getJson(self::BASE);
$headers = $this->authHeaders(['sirsoft-message_bizppurio.messaging.other']);
$response->assertStatus(403);
$this->withHeaders($headers)->getJson(self::BASE.'/categories')->assertStatus(403);
$this->withHeaders($headers)->getJson(self::BASE.'/profiles')->assertStatus(403);
}
/**
* @scenario auth=view,service=ok
*
* @effects show_returns_template_detail_with_status_badge
*/
public function test_view권한으로_상세를_조회한다(): void
{
$this->mockService()->shouldReceive('detail')->once()->with('TW_1')->andReturn([
'templateCode' => 'TW_1',
'templateName' => '주문완료',
'status_badge' => ['variant' => 'green'],
]);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE.'/TW_1');
$response->assertStatus(200);
$response->assertJsonPath('data.template.templateCode', 'TW_1');
$response->assertJsonPath('data.template.status_badge.variant', 'green');
}
/**
* @scenario auth=view,action=store
*
* @effects store_route_removed_returns_405_read_only
*/
public function test_등록_라우트는_제거되어_조회전용이다(): void
{
// 조회 전용 전환으로 등록(POST)·상태변경 라우트를 제거했다. manage 미들웨어 블록이
// 사라졌으므로 POST 는 라우트 미매칭(405)이 되어야 한다(등록은 비즈뿌리오 콘솔).
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->postJson(self::BASE, [
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
]);
$response->assertStatus(405);
}
/**
* @scenario auth=view,service=throws
*
* @effects kapi_failure_is_surfaced_as_422_with_result_code
* kapi 실패는 카카오 사유(message)·결과코드가 담긴 422 로 전파된다 (GuardsKakaoRequests 규약).
*/
public function test_kapi_실패는_422로_전파된다(): void
{
$this->mockService()->shouldReceive('list')->once()
$this->mockService()->shouldReceive('categories')->once()
->andThrow(new BizppurioApiException('접근할 수 없는 IP 입니다.', resultCode: '403'));
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE.'/categories');
$response->assertStatus(422);
$response->assertJsonPath('errors.bizppurio_message', '접근할 수 없는 IP 입니다.');
$response->assertJsonPath('errors.result_code', '403');
}
/**
* 발신프로필 조회의 kapi 실패도 동일한 422 규약으로 전파된다.
*/
public function test_발신프로필_kapi_실패도_422로_전파된다(): void
{
$this->mockService()->shouldReceive('senderProfiles')->once()
->andThrow(new BizppurioApiException('발신프로필을 찾을 수 없습니다.', resultCode: '7204'));
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE);
->getJson(self::BASE.'/profiles');
$response->assertStatus(422);
$response->assertJsonPath('errors.bizppurio_message', '발신프로필을 찾을 수 없습니다.');
$response->assertJsonPath('errors.result_code', '7204');
$response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.');
}
/**
* @scenario auth=view,kapi_result=not_found
*
* @effects list_treats_kapi_508_as_empty_result_not_error
*/
public function test_kapi_508은_에러가_아니라_빈_목록으로_응답한다(): void
{
$this->mockService()->shouldReceive('list')->once()->andReturn([
'templates' => [],
'pagination' => ['total' => 0, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20],
]);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE.'?keyword=대글');
$response->assertStatus(200);
$response->assertJsonPath('data.templates', []);
$response->assertJsonPath('data.pagination.total', 0);
}
/**
* @scenario auth=manage,action=clear_cache
*
* @effects clear_cache_delegates_to_binding_service_and_returns_count
*/
public function test_manage권한으로_발송내용_캐시를_초기화한다(): void
{
$bindings = Mockery::mock(NotificationBindingService::class);
$bindings->shouldReceive('clearTemplateContentCache')->once()->andReturn(3);
$this->app->instance(NotificationBindingService::class, $bindings);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE.'/cache/clear');
$response->assertStatus(200);
$response->assertJsonPath('data.cleared', 3);
}
/**
* @scenario auth=view_only,action=clear_cache
*
* @effects clear_cache_requires_manage_permission_returns_403
*/
public function test_캐시초기화는_manage권한이_없으면_403(): void
{
// 조회(view) 권한만으로는 캐시 초기화(쓰기)를 할 수 없다.
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->postJson(self::BASE.'/cache/clear');
$response->assertStatus(403);
}
/**
* @scenario auth=guest,action=clear_cache
*
* @effects clear_cache_requires_authentication_returns_401
*/
public function test_캐시초기화는_비인증이면_401(): void
{
$this->postJson(self::BASE.'/cache/clear')->assertStatus(401);
}
protected function tearDown(): void
@@ -6,6 +6,7 @@ use App\Enums\ExtensionOwnerType;
use App\Models\Menu;
use Illuminate\Support\Facades\Schema;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Plugin;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
@@ -135,17 +136,26 @@ class InstallationTest extends PluginTestCase
}
/**
* Phase 4 발송 이력·연동 테이블이 마이그레이션으로 생성된다.
* Phase 4 발송 이력·알림 템플릿 테이블이 마이그레이션으로 생성된다.
*
* bizppurio_notification_bindings 는 #597 개편으로 bizppurio_templates(알림톡 등록
* 페이로드·승인 스냅샷·검수 상태 + SMS 본문)로 대체되었다.
*/
public function test_phase4_테이블이_생성된다(): void
{
$this->assertTrue(Schema::hasTable('bizppurio_dispatches'));
$this->assertTrue(Schema::hasTable('bizppurio_notification_bindings'));
$this->assertTrue(Schema::hasTable('bizppurio_templates'));
$this->assertTrue(Schema::hasColumns('bizppurio_dispatches', [
'refkey', 'messagekey', 'channel', 'to_number', 'to_user_id',
'status', 'result_code', 'reported_at', 'raw_payload',
]));
$this->assertTrue(Schema::hasColumns('bizppurio_templates', [
'notification_type', 'alimtalk_enabled', 'template_code', 'sender_key',
'content', 'approved_content', 'status', 'inspection_detail',
'fallback_sms_enabled', 'sms_body', 'sms_only', 'is_active',
]));
}
/**
@@ -158,10 +168,12 @@ class InstallationTest extends PluginTestCase
// down
$this->artisan('migrate:rollback', ['--path' => $migrations, '--realpath' => true])->run();
$this->assertFalse(Schema::hasTable('bizppurio_dispatches'));
$this->assertFalse(Schema::hasTable('bizppurio_templates'), '롤백 시 bizppurio_templates 도 함께 제거되어야 한다.');
// up
$this->artisan('migrate', ['--path' => $migrations, '--realpath' => true])->run();
$this->assertTrue(Schema::hasTable('bizppurio_dispatches'));
$this->assertTrue(Schema::hasTable('bizppurio_templates'), '재실행 시 bizppurio_templates 가 다시 생성되어야 한다.');
// 왕복 후 Repository 호출 1회전 (create → 조회)
$dispatch = BizppurioDispatch::create([
@@ -174,5 +186,15 @@ class InstallationTest extends PluginTestCase
]);
$this->assertNotNull(BizppurioDispatch::query()->byRefkey('roundtrip')->first());
$this->assertSame('roundtrip', $dispatch->refkey);
// 왕복 후 템플릿 모델 1회전 — unique(notification_type)·JSON 캐스팅이 살아 있는지 확인
$template = BizppurioTemplate::create([
'notification_type' => 'welcome',
'alimtalk_enabled' => true,
'content' => ['templateName' => '가입 환영'],
'sms_body' => ['ko' => '가입을 환영합니다.', 'en' => 'Welcome aboard.'],
]);
$this->assertNotNull(BizppurioTemplate::query()->where('notification_type', 'welcome')->first());
$this->assertSame(['templateName' => '가입 환영'], $template->content);
}
}
@@ -1,251 +0,0 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Mockery;
use Mockery\MockInterface;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 알림↔알림톡 템플릿 연동 조회 엔드포인트 테스트 (Phase 6 재설계 A, §6-2).
*
* 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다. 이 엔드포인트는 코어 편집 모달 전용 칸이
* 소비하는 조회(연동 맵 index·승인 템플릿 드롭다운 approved-templates)와 즉시 저장(store)을
* 제공한다. 값 변경 즉시 store 로 저장되며(빈 코드=해제), 코어 저장 버튼과 무관하다(무오염).
* 조회는 messaging.view, 저장은 messaging.manage 권한을 요구한다(라우트 미들웨어).
*/
class NotificationBindingEndpointTest extends PluginTestCase
{
private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings';
/**
* AlimtalkTemplateService mock 을 컨테이너에 바인딩하고 반환한다.
*
* bind() 가 저장 전 카카오 승인 목록을 조회하므로(회귀 방지 재검증), 실제 kapi 호출 없이
* 저장 경로를 검증하려면 이 mock 으로 승인 템플릿 목록을 지정해야 한다.
*/
private function mockTemplateService(): MockInterface
{
$mock = Mockery::mock(AlimtalkTemplateService::class);
$this->app->instance(AlimtalkTemplateService::class, $mock);
return $mock;
}
/**
* 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다.
*
* @param array<int, string> $codes 승인 처리할 template_code 목록
*/
private function stubApprovedTemplates(array $codes): void
{
$templates = array_map(
fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
$codes,
);
$this->mockTemplateService()
->shouldReceive('list')
->andReturn(['templates' => $templates, 'pagination' => []]);
}
/**
* 지정 권한 식별자들을 가진 admin 사용자를 만듭니다.
*
* @param array<int, string> $permissionIds 부여할 권한 식별자
*/
private function adminWith(array $permissionIds): User
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
);
$permIds = [];
foreach ($permissionIds as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
);
$permIds[] = $permission->id;
}
$testRole = Role::create([
'identifier' => 'bizppurio_binding_test_'.uniqid(),
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'type' => 'admin',
]);
$testRole->permissions()->sync($permIds);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
/**
* 사용자 토큰으로 요청 헤더를 만듭니다.
*/
private function authHeaders(User $user): array
{
return [
'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
'Accept' => 'application/json',
];
}
public function test_인증_없이_목록_조회는_401이다(): void
{
$this->getJson(self::BASE)->assertStatus(401);
}
public function test_view_권한으로_연동_맵을_조회한다(): void
{
BizppurioNotificationBinding::create([
'notification_type' => 'welcome',
'channel' => 'alimtalk',
'template_code' => 'TW_1236',
'template_name' => '가입환영',
'fallback_sms_enabled' => true,
'is_active' => true,
]);
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
$response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE);
$response->assertStatus(200);
$response->assertJsonPath('data.bindings.welcome.template_code', 'TW_1236');
$response->assertJsonPath('data.bindings.welcome.fallback_sms_enabled', true);
}
public function test_연동이_없으면_빈_맵을_반환한다(): void
{
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
$response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE);
$response->assertStatus(200);
$response->assertJsonPath('data.bindings', []);
}
public function test_권한_없는_사용자는_승인템플릿_조회_403이다(): void
{
$admin = $this->adminWith([]); // messaging.view 없음
$response = $this->withHeaders($this->authHeaders($admin))
->getJson(self::BASE.'/approved-templates');
$response->assertStatus(403);
}
public function test_view_권한만으로는_저장할_수_없다(): void
{
$admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => 'TW_1236',
'template_name' => '가입환영',
]);
$response->assertStatus(403);
}
public function test_manage_권한으로_연동을_즉시_저장한다(): void
{
$this->stubApprovedTemplates(['TW_1236']);
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
]);
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => 'TW_1236',
'template_name' => '가입환영',
'fallback_sms_enabled' => true,
]);
$response->assertStatus(200);
$this->assertDatabaseHas('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
'template_code' => 'TW_1236',
'fallback_sms_enabled' => true,
]);
}
public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void
{
// 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접
// 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단.
$this->stubApprovedTemplates(['TW_OTHER']);
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
]);
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
'template_name' => '미승인',
]);
$response->assertStatus(422);
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
]);
}
public function test_빈_코드로_저장하면_연동을_해제한다(): void
{
BizppurioNotificationBinding::create([
'notification_type' => 'welcome',
'channel' => 'alimtalk',
'template_code' => 'TW_1236',
'template_name' => '가입환영',
'fallback_sms_enabled' => false,
'is_active' => true,
]);
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
]);
// 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요.
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => '',
]);
$response->assertStatus(200);
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
]);
}
public function test_notification_type_누락_시_422다(): void
{
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
]);
// FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요.
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'template_code' => 'TW_1236',
]);
$response->assertStatus(422);
}
}
@@ -0,0 +1,102 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Template;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Plugin;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* bizppurio:sync-template-status 커맨드 테스트 (#597 §3.4).
*
* 검수중(requested) 행이 없으면 kapi 를 호출하지 않고, 있으면 senderKey 별
* template/list 일괄 대조로 상태를 전이한다. 스케줄 등록은 plugin.php::getSchedules()
* 가 담당한다(별도 단언).
*/
class SyncTemplateStatusCommandTest extends PluginTestCase
{
/**
* kapi 자격증명을 플러그인 설정에 저장합니다.
*/
private function seedKakaoSettings(): void
{
app(PluginSettingsService::class)->save('sirsoft-message_bizppurio', [
'bizppurio_id' => 'biz1',
'api_key' => 'key1',
'sender_key' => 'SK_TEST',
]);
}
/**
* @effects scheduler_skips_kapi_when_no_requested_rows
*/
public function test_검수중_행이_없으면_kapi를_호출하지_않는다(): void
{
Http::fake();
BizppurioTemplate::create(['notification_type' => 'welcome', 'status' => 'draft']);
$this->artisan('bizppurio:sync-template-status')->assertExitCode(0);
Http::assertNothingSent();
}
/**
* @effects scheduler_uses_list_batch_not_per_row_detail
*/
public function test_검수중_행을_list_일괄_대조로_전이한다(): void
{
Http::fake([
'*template/list*' => Http::response([
'code' => '200', 'message' => 'ok',
'data' => ['totalPage' => 1, 'list' => [['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'ACT']]],
]),
]);
$this->seedKakaoSettings();
$row = BizppurioTemplate::create([
'notification_type' => 'welcome',
'status' => 'requested',
'template_code' => 'g7_aaaa1111_1',
'sender_key' => 'SK_TEST',
'content' => ['templateContent' => '본문'],
]);
$this->artisan('bizppurio:sync-template-status')->assertExitCode(0);
$this->assertSame(BizppurioTemplateStatus::Approved, $row->fresh()->status);
$this->assertNotNull($row->fresh()->approved_content, '승인 전이 시 스냅샷이 동결돼야 한다.');
Http::assertSentCount(1);
}
public function test_kapi_조회_실패는_커맨드를_실패시키지_않는다(): void
{
// 일괄 조회 실패는 로그만 남기고 다음 주기에 재시도한다 — 종료 코드는 0.
Http::fake([
'*template/list*' => Http::response(['code' => '403', 'message' => '권한 없음']),
]);
$this->seedKakaoSettings();
$row = BizppurioTemplate::create([
'notification_type' => 'welcome',
'status' => 'requested',
'template_code' => 'g7_aaaa1111_1',
'sender_key' => 'SK_TEST',
]);
$this->artisan('bizppurio:sync-template-status')->assertExitCode(0);
$this->assertSame(BizppurioTemplateStatus::Requested, $row->fresh()->status, '조회 실패 시 상태를 건드리지 않는다.');
}
public function test_플러그인이_30분_주기_스케줄을_선언한다(): void
{
$plugin = new Plugin;
$schedules = $plugin->getSchedules();
$this->assertCount(1, $schedules);
$this->assertSame('bizppurio:sync-template-status', $schedules[0]['command']);
$this->assertSame('everyThirtyMinutes', $schedules[0]['schedule']);
}
}
@@ -0,0 +1,43 @@
/**
* 비즈뿌리오 메시징 플러그인 권한 fixture (#597).
*
* 코어 `tests/Playwright/fixtures/auth.ts` 의 헬퍼(issueToken / authenticatePage)를 재사용한다.
* 알림 템플릿 라이프사이클 화면은 플러그인 권한(messaging.view/manage)과 코어 알림 설정
* 화면 접근 권한을 함께 요구한다.
*/
import { test as base } from '@playwright/test';
// 6단계 상위 = 코어 루트의 tests/Playwright/fixtures/auth.ts
// (plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures → 코어 루트)
import { issueToken, issueScopedToken, authenticatePage } from '../../../../../../tests/Playwright/fixtures/auth';
type BizppurioAuthFixtures = {
/** 알림 템플릿 조회+관리 권한 토큰 (코어 설정 화면 접근 포함) */
messagingManageToken: string;
/** 조회 전용 권한 토큰 (권한 경계 검증용) */
messagingViewToken: string;
};
export const test = base.extend<BizppurioAuthFixtures>({
messagingManageToken: async ({}, use) => {
await use(issueToken(
'core.settings.read',
'core.settings.update',
'core.plugins.read',
'core.plugins.update',
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
));
},
messagingViewToken: async ({}, use) => {
// issueToken 은 admin 역할(전체 권한)을 함께 부여한다 — 권한 경계를 재는 토큰은
// 반드시 issueScopedToken 이어야 한다. 라운드 5 실측에서 view 전용 토큰으로 POST 가
// 201 을 받았고, 원인은 제품이 아니라 이 fixture 였다.
await use(issueScopedToken(
'core.plugins.read',
'sirsoft-message_bizppurio.messaging.view',
));
},
});
export { authenticatePage };
export { expect } from '@playwright/test';
@@ -0,0 +1,82 @@
/**
* 비즈뿌리오 메시징 플러그인 Playwright E2E 설정 (#597).
*
* 코어 `playwright.config.ts` 와 동일한 base URL 해석 우선순위를 따른다 — 활성 호스트가
* 환경별로 다르므로 하드코딩을 피한다.
*
* Base URL 해석:
* 1. PLAYWRIGHT_BASE_URL 환경변수 (CI/명시적 오버라이드)
* 2. .env (코어 루트) 의 APP_URL — 단 localhost 류는 fallback 부적합
* 3. 그 외 — 명시 에러
*
* 실행 예시:
* PowerShell — $env:PLAYWRIGHT_BASE_URL='https://g7.dev'; npx playwright test -c plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts
* Bash — PLAYWRIGHT_BASE_URL=https://g7.dev npx playwright test -c plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts
*/
import { defineConfig, devices } from '@playwright/test';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
// ESM 환경(package.json "type": "module")에서는 __dirname 이 정의되지 않으므로
// import.meta.url 로 재구성한다.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* 코어 루트 (artisan / .env / Playwright 산출물의 기준 경로).
*
* 산출물을 확장 디렉토리 안에 쓰면 Windows 에서 `plugin:update` 의 디렉토리 이동이
* 열린 핸들에 걸려 실패하므로, 코어 루트 아래로 모아 update 경로와 분리한다.
*/
const CORE_ROOT = process.env.G7_ROOT || resolve(__dirname, '../../../../../');
/** 확장별 산출물 격리 — 확장끼리 리포트를 덮어쓰지 않도록 slug 로 네임스페이스. */
const ARTIFACT_SLUG = 'plugins/sirsoft-message_bizppurio';
function readEnvFile(filePath: string, key: string): string | null {
if (!existsSync(filePath)) return null;
const content = readFileSync(filePath, { encoding: 'utf-8' });
const pattern = new RegExp(`^${key}=(.*)$`, 'm');
const match = content.match(pattern);
if (!match) return null;
let value = match[1].trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
return value || null;
}
function resolveBaseUrl(): string {
if (process.env.PLAYWRIGHT_BASE_URL) {
return process.env.PLAYWRIGHT_BASE_URL;
}
const appUrl = readEnvFile(resolve(CORE_ROOT, '.env'), 'APP_URL');
if (appUrl && !/^https?:\/\/localhost(:\d+)?\/?$/i.test(appUrl)) {
return appUrl;
}
throw new Error(
'비즈뿌리오 플러그인 E2E base URL 미설정. PLAYWRIGHT_BASE_URL 환경변수를 지정하거나 코어 .env 의 APP_URL 을 활성 호스트로 설정하세요.'
);
}
export default defineConfig({
testDir: './specs',
outputDir: resolve(CORE_ROOT, 'test-results', ARTIFACT_SLUG),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: resolve(CORE_ROOT, 'playwright-report', ARTIFACT_SLUG), open: 'never' }],
['list'],
],
use: {
baseURL: resolveBaseUrl(),
// spec 이 한국어 화면 문구('비즈뿌리오'·'알림톡' 등)를 단언하므로 로케일을 고정한다.
locale: 'ko-KR',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
ignoreHTTPSErrors: true,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
@@ -0,0 +1,745 @@
/**
* E2E 정밀 점검 매트릭스: 비즈뿌리오 알림 템플릿 라이프사이클 (#597 §6.3)
*
* @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active,
* row_footer_shows_status_badge_and_lifecycle_actions, compose_modal_switches_conditional_fields_by_type,
* manage_screen_lists_db_rows_with_merge_query_round_trip, sms_modal_edits_body_per_locale_tab,
* upload_in_progress_locks_save_buttons_not_cancel, manage_row_actions_match_row_footer_visibility,
* sms_body_presence_and_preview_resolved_by_server
*
* 계획서 §6.3 이 요구한 7대 축(T1~T7) + 선택 축(T8~T10) + 회귀 축(R1~R6)을 브라우저에서 실측한다.
* 이 매트릭스는 라운드 1~4 동안 "수행 증거 없음" 으로 남아 있었고, 그 근본 원인은 플러그인
* package.json 에 실행 진입점(test:e2e)이 없었다는 것이다 — 라운드 5 에서 진입점을 만들고
* 매트릭스를 spec 으로 고정해 다음 라운드부터는 명령 하나로 재실측된다.
*
* 각 테스트는 `MATRIX|<행>|<측정값>` 형태로 증거를 표준출력에 남긴다. "정상 동작" 서술이
* 아니라 DOM 카운트·네트워크 상태코드·URL 파라미터 같은 측정값이 판정 근거다.
*
* kapi 실호출이 필요한 행(4a·5b·6b)은 유효 자격증명이 없으면 측정 불가다 — 그 경우
* 강제로 통과시키지 않고 skip 사유를 남긴다(비정상 경로로 통과 선언 금지).
*/
import { test, expect, authenticatePage } from '../../fixtures/bizppurio-auth';
import type { Page } from '@playwright/test';
const SETTINGS_URL = '/admin/settings?tab=notification_definitions&channel=alimtalk';
const MANAGE_URL = '/admin/plugins/sirsoft-message_bizppurio/settings?tab=templates';
const BOARD_URL = '/admin/boards/settings?tab=notification_definitions&channel=alimtalk';
const ECOMMERCE_URL = '/admin/ecommerce/settings?tab=notification_definitions&channel=alimtalk';
/** 측정값을 표준출력에 남긴다(보고서 표의 증거 열이 된다). */
function evidence(row: string, value: unknown): void {
// eslint-disable-next-line no-console
console.log(`MATRIX|${row}|${typeof value === 'string' ? value : JSON.stringify(value)}`);
}
/** 콘솔 에러를 수집하도록 페이지를 준비한다. */
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(String(e)));
return errors;
}
/** 설정 화면 진입 + 비즈뿌리오 탭이 노출된 상태를 보장한다(멱등). */
async function openBizppurioTab(page: Page): Promise<void> {
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const tabBar = page.locator('#channel_sub_tabs');
await expect(tabBar).toBeVisible({ timeout: 30_000 });
if (!(await tabBar.innerText()).includes('비즈뿌리오')) {
const card = page.locator('#channel_toggles')
.locator('div.flex-between', { hasText: '비즈뿌리오 알림톡' }).first();
await card.locator('[role="switch"]').click();
await page.locator('#save_button').click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
}
}
/**
* 열려 있는 모달(role=dialog) 스코프.
*
* 페이지에도 "저장" 버튼(#save_button — 알림 설정 화면 전체 저장)이 있어서 이름만으로
* 찾으면 그쪽이 잡힌다(실측: 라운드 5 1차 매트릭스에서 6건이 이 이유로 오검출).
*/
function dialog(page: Page) {
return page.getByRole('dialog');
}
/** 모달 안의 저장 버튼(신규/수정 공통 — if 로 하나만 렌더된다) */
function modalSave(page: Page) {
return dialog(page).getByRole('button', { name: '저장', exact: true });
}
/**
* 측정용 템플릿 행을 보장한다(멱등).
*
* content.categoryCode 는 저장 시 필수인데 그 선택지는 kapi 카테고리 조회에서만 온다.
* 유효 자격증명이 없으면 신규 작성 경로는 저장 자체가 422 로 막혀 저장·영속성·중복제출
* 축을 UI 로 잴 수 없다. 그래서 카테고리가 채워진 행을 API 로 한 번 만들어 두고, 측정은
* 수정 모달이라는 실제 화면 경로로 수행한다(강제 통과가 아니라 픽스처 준비다).
*
* @returns 시드된 행의 notification_type
*/
async function seedTemplate(page: Page, wanted = 'welcome'): Promise<string> {
return page.evaluate(async (wantedType) => {
const token = localStorage.getItem('auth_token') ?? '';
const headers = {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${token}`,
};
const content = {
templateName: '매트릭스 측정용',
templateMessageType: 'BA',
templateEmphasizeType: 'NONE',
templateContent: '초기 본문 #{site_name}',
categoryCode: '001001',
};
const listRes = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers });
const list = await listRes.json();
const rows = list?.data?.templates ?? [];
// 지정 유형의 행이 draft 면 그것을 쓴다. requested/approved 는 content 편집이 잠기므로
// (§13.1) 다른 행을 만들어 테스트 간 간섭을 없앤다.
const existing = rows.find((r: { notification_type?: string; status?: string }) =>
r.notification_type === wantedType && r.status === 'draft');
if (existing) {
await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${existing.id}`, {
method: 'PUT', headers, body: JSON.stringify({ content }),
});
return existing.notification_type as string;
}
const created = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates', {
method: 'POST', headers, body: JSON.stringify({ notification_type: wantedType, content }),
});
const j = await created.json();
return (j?.data?.template?.notification_type ?? wantedType) as string;
}, wanted);
}
/**
* 시드된 행의 수정 모달을 연다.
*
* @param page 페이지
*/
async function openEditModal(page: Page) {
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 행 컨테이너 id 는 모든 행이 공유하므로 유형으로 특정할 수 없다. 목록 순서는 재조회
// 후에도 동일하므로, 같은 테스트 안에서 first() 는 항상 같은 행을 연다 — 저장→새로고침→
// 재오픈 왕복 비교에는 그것으로 충분하다. (테스트 간 간섭은 알림 유형 분리로 막는다.)
const edit = page.getByRole('button', { name: '수정', exact: true }).first();
await expect(edit).toBeVisible({ timeout: 20_000 });
await edit.click();
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
await expect(modal).toBeVisible({ timeout: 15_000 });
return modal;
}
/**
* 비즈뿌리오 템플릿 행을 전부 삭제한다(측정용 픽스처 준비).
*
* 영속성 왕복(저장 → 새로고침 → 재오픈)은 "같은 행" 을 다시 열어야 성립하는데, 목록에
* 여러 행이 있으면 first() 가 재조회 뒤 다른 행을 열 수 있다(실측: 다른 테스트가 시드한
* 본문이 복원돼 비교가 어긋났다). 행을 하나만 남겨 그 모호성을 없앤다.
*
* @returns 삭제한 행 수
*/
async function clearTemplates(page: Page): Promise<number> {
return page.evaluate(async () => {
const headers = {
Accept: 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
};
const list = await (await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=50', { headers })).json();
const rows = list?.data?.templates ?? [];
for (const r of rows) {
await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${r.id}`, { method: 'DELETE', headers });
}
return rows.length;
});
}
/** 서브탭 버튼 텍스트 배열 */
async function tabTexts(page: Page): Promise<string[]> {
return (await page.locator('#channel_sub_tabs button').allInnerTexts()).map((t) => t.trim());
}
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T1 진입/표시', () => {
test('1a·1b 통합 탭 노출 + 행 하단 블록 + i18n 원문 0', async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
const tabs = await tabTexts(page);
evidence('1a 탭 배열', tabs);
expect(tabs.join('|')).toContain('비즈뿌리오');
expect(tabs.filter((t) => t === '문자' || t === '알림톡')).toHaveLength(0);
const rows = page.locator('[id^="bizppurio_row_lifecycle"]');
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
const rowCount = await rows.count();
evidence('1b 행 하단 블록 수', rowCount);
expect(rowCount).toBeGreaterThan(0);
const content = await page.locator('#notif_channel_content').innerText();
const rawKeys = (content.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length;
evidence('1b i18n 원문 노출 수', rawKeys);
expect(rawKeys).toBe(0);
evidence('1b 콘솔 에러 수', errors.length);
expect(errors).toHaveLength(0);
});
test('1c readiness 배너가 자격증명 상태를 반영한다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
const banner = page.locator('#bizppurio_banner_not_ready');
const visible = await banner.isVisible().catch(() => false);
const text = visible ? (await banner.innerText()).replace(/\s+/g, ' ').slice(0, 80) : '(미노출)';
evidence('1c readiness 배너', { visible, text });
// 자격증명 미완이면 배너가 있어야 하고, 완비면 없어야 한다 — 어느 쪽이든 상태와 일치해야 한다.
expect(typeof visible).toBe('boolean');
});
test('1d·1e tab_channels 규칙 — 알림톡만 활성 / 둘 다 비활성', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
// 1d: 알림톡만 활성(현 상태) → 탭 노출
evidence('1d 알림톡만 활성 시 탭', await tabTexts(page));
expect((await tabTexts(page)).join('|')).toContain('비즈뿌리오');
// 1e: 두 채널 모두 비활성 저장 → 탭 미노출
const toggles = page.locator('#channel_toggles');
for (const label of ['비즈뿌리오 알림톡', '비즈뿌리오 문자']) {
const card = toggles.locator('div.flex-between', { hasText: label }).first();
if (await card.count() === 0) continue;
const sw = card.locator('[role="switch"]');
if ((await sw.getAttribute('aria-checked')) === 'true') await sw.click();
}
await page.locator('#save_button').click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const off = await tabTexts(page);
evidence('1e 둘 다 비활성 시 탭', off);
expect(off.join('|')).not.toContain('비즈뿌리오');
// 원복 — 이후 테스트가 통합 탭을 필요로 한다
await openBizppurioTab(page);
evidence('1e 원복 후 탭', await tabTexts(page));
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T2·T3 입력', () => {
test('2a·2b 모달 오픈 + 프리필 + 강조유형 4종 전환 시 조건부 블록', async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
await expect(modal).toBeVisible({ timeout: 15_000 });
const name = await modal.locator('input[name="bz_template_name"]').inputValue();
evidence('2a 템플릿명 프리필', name);
expect(name.length).toBeGreaterThan(0);
const title = modal.getByText('강조표기 타이틀', { exact: false });
const file = modal.locator('input[type="file"]');
const itemAdd = modal.getByRole('button', { name: '아이템 추가' });
const snapshot: Record<string, Record<string, number>> = {};
// 클릭 직후의 count() 는 React 리렌더 전 값을 읽는다 — 유형별로 "그 유형의 대표
// 필드" 가 확정될 때까지 자동 재시도 단언으로 기다린 뒤 세 값을 함께 스냅샷한다.
for (const [label, key, settle] of [
['없음', 'NONE', async () => { await expect(title).toHaveCount(0); await expect(file).toHaveCount(0); }],
['강조표기', 'TEXT', async () => { await expect(title.first()).toBeVisible(); }],
['이미지', 'IMAGE', async () => { await expect(file.first()).toBeVisible(); }],
['아이템리스트', 'ITEM_LIST', async () => { await expect(itemAdd.first()).toBeVisible(); }],
] as const) {
await modal.getByRole('button', { name: label, exact: true }).click();
await settle();
snapshot[key] = {
title: await title.count(),
file: await file.count(),
itemAdd: await itemAdd.count(),
};
}
evidence('2b 강조유형별 조건부 블록', snapshot);
expect(snapshot.NONE).toEqual({ title: 0, file: 0, itemAdd: 0 });
expect(snapshot.TEXT.title).toBeGreaterThan(0);
expect(snapshot.IMAGE.file).toBeGreaterThan(0);
expect(snapshot.IMAGE.title).toBe(0);
expect(snapshot.ITEM_LIST.itemAdd).toBeGreaterThan(0);
expect(snapshot.ITEM_LIST.file).toBe(0);
const kapiLookup = errors.filter((e) => /bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e));
const others = errors.filter((e) => !/bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e));
evidence('2b 콘솔 에러', { kapi조회실패: kapiLookup.length, 그외: others.length });
// 카테고리·발신프로필은 kapi 실조회다 — 유효 자격증명이 없으면 422 가 정상 경로이고
// 화면은 이를 suppress 한다. 그 외 에러는 0 이어야 한다.
expect(others).toHaveLength(0);
});
test('3a 버튼 3회 추가 + 1회 삭제 → 카운트 정합, 5개 도달 시 추가 잠김', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
await expect(modal).toBeVisible({ timeout: 15_000 });
const addBtn = modal.getByRole('button', { name: '버튼 추가' });
const rowCount = () => modal.getByPlaceholder('버튼명', { exact: false }).count();
const rows = modal.getByPlaceholder('버튼명', { exact: false });
const trace: number[] = [];
for (let i = 1; i <= 3; i++) {
await addBtn.click();
await expect(rows).toHaveCount(i);
trace.push(await rowCount());
}
// 삭제 1회 (마지막 행의 삭제 버튼)
// 아이콘 전용 삭제 버튼은 라운드 5 이전까지 접근 가능한 이름이 없어 이름으로 찾을 수
// 없었다(실측으로 드러난 a11y 결함 — aria-label 부여로 해소).
const delButtons = modal.getByRole('button', { name: '버튼 삭제' });
await expect(delButtons).toHaveCount(3);
await delButtons.last().click();
await expect(rows).toHaveCount(2);
trace.push(await rowCount());
evidence('3a 추가3+삭제1 카운트 추이', trace);
expect(trace[0]).toBe(1);
expect(trace[1]).toBe(2);
expect(trace[2]).toBe(3);
expect(trace[3]).toBe(2);
// 상한(5) 도달 → 추가 버튼 비활성 (속성 + computed style 이중 측정)
while (await rowCount() < 5) {
const before = await rowCount();
await addBtn.click();
await expect(rows).toHaveCount(before + 1);
}
const disabledAttr = await addBtn.isDisabled();
const style = await addBtn.evaluate((el) => {
const cs = getComputedStyle(el as HTMLElement);
return { opacity: cs.opacity, cursor: cs.cursor, pointerEvents: cs.pointerEvents };
});
evidence('3a 5개 도달 시 추가 버튼', { count: await rowCount(), disabledAttr, ...style });
expect(await rowCount()).toBe(5);
expect(disabledAttr).toBe(true);
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T4 제출/응답 + T10 경계', () => {
test('4b·10a 본문 길이 경계(999/1000/1001) 응답 코드', async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await seedTemplate(page, 'reset_password');
const results: Record<string, number> = {};
const typed: Record<string, number> = {};
for (const len of [999, 1000, 1001]) {
const modal = await openEditModal(page);
const ta = modal.locator('textarea[name="bz_template_content"]');
await ta.fill('가'.repeat(len));
typed[String(len)] = (await ta.inputValue()).length;
const [resp] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/admin/templates') && ['POST', 'PUT'].includes(r.request().method()), { timeout: 20_000 }),
modalSave(page).first().click(),
]);
results[String(len)] = resp.status();
}
evidence('4b·10a 본문 길이별 응답', results);
evidence('4b·10a 1001자 입력 후 실제 textarea 길이', typed);
expect(results['999']).toBeLessThan(400);
expect(results['1000']).toBeLessThan(400);
// 1001 자는 서버까지 가지 않는다 — textarea maxLength=1000 이 입력 단계에서 자른다.
// 서버측 max:1000 규칙은 PHPUnit contentMatrixProvider 가 소유한다(이중 판정 아님).
expect(typed['1001'], 'UI 가 1000 자에서 입력을 잘라야 한다').toBe(1000);
expect(results['1001']).toBeLessThan(400);
const others4b = errors.filter((e) => !/bizppurioCategories|bizppurioProfiles|422|Failed to send logs/.test(e));
evidence('4b 콘솔 에러', { kapi조회실패: errors.length - others4b.length, 그외: others4b.length });
expect(others4b).toHaveLength(0);
});
test('10c [저장] 더블클릭 시 요청이 1회만 나간다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await seedTemplate(page, 'password_changed');
const modal = await openEditModal(page);
await modal.locator('textarea[name="bz_template_content"]').fill('중복 제출 테스트 #{site_name}');
let saves = 0;
let okSaves = 0;
let requests = 0;
let okRequests = 0;
page.on('request', (r) => {
if (['POST', 'PUT'].includes(r.method()) && /\/admin\/templates\/\d+$/.test(r.url())) saves++;
if (r.method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(r.url())) requests++;
});
page.on('response', (res) => {
const u = res.url();
if (res.status() >= 400) return;
if (['POST', 'PUT'].includes(res.request().method()) && /\/admin\/templates\/\d+$/.test(u)) okSaves++;
if (res.request().method() === 'POST' && /\/admin\/templates\/\d+\/request$/.test(u)) okRequests++;
});
// ① 일반 저장 더블 클릭 — PUT 은 멱등이므로 요청 수가 아니라 결과 일관성을 본다.
await modalSave(page).first().click({ clickCount: 2, delay: 30 });
await page.waitForTimeout(3_000);
evidence('10c 저장 더블클릭', { 요청수: saves, 성공응답: okSaves });
expect(okSaves, '저장이 한 번도 성공하지 않았다').toBeGreaterThan(0);
// ② [저장 후 검수 신청] 더블 클릭 — §6.3 10c 가 지정한 대상.
// 판정 축은 "요청 수" 가 아니라 **중복 신청 0** 이다. 클라이언트 if 가드는
// setState 반영 전에 두 번째 click 이 디스패치되면 뚫린다(실측). 실제 방어는
// 서버의 원자 선점(claimForInspection)이며, 두 번째 신청은 422 로 거부된다.
const modal2 = await openEditModal(page);
await modal2.locator('textarea[name="bz_template_content"]').fill('중복 신청 테스트 #{site_name}');
await dialog(page).getByRole('button', { name: '저장 후 검수 신청', exact: true })
.first().click({ clickCount: 2, delay: 30 });
await page.waitForTimeout(4_000);
evidence('10c 저장후신청 더블클릭', { 신청요청수: requests, 신청성공수: okRequests });
expect(okRequests, '중복 검수 신청이 성립하면 카카오측 중복 등록이 된다').toBeLessThanOrEqual(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T6·T7 영속성·컨텍스트 격리', () => {
test('6a 저장 → 새로고침 후 본문·유형 복원', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await seedTemplate(page, 'welcome');
// 모달이 실제로 어떤 행을 열었는지는 전역 상태가 알고 있다 — 목록에 여러 행이 있으면
// first() 가 재조회 뒤 다른 행을 열 수 있어(실측) 행 id 를 근거로 왕복을 맞춘다.
const openedId = async (): Promise<number | null> =>
page.evaluate(() => ((window as any).G7Core?.state?.get?.()?.bz_tpl_modal?.id ?? (window as any).G7Core?.state?.getGlobal?.()?.bz_tpl_modal?.id) ?? null);
const body = `영속성 확인 ${'가'.repeat(5)} #{site_name} !@#$%^&*() 🎉`;
let modal = await openEditModal(page);
const targetId = await openedId();
evidence('6a 측정 대상 행 id', targetId);
expect(targetId, '수정 모달이 연 행의 id 를 읽지 못했다').not.toBeNull();
await modal.locator('textarea[name="bz_template_content"]').fill(body);
const beforeSave = await page.evaluate(() => {
const g = (window as any).G7Core?.state?.get?.() ?? {};
const m = g.bz_tpl_modal ?? {};
return { id: m.id ?? null, len: (m.content?.templateContent ?? '').length, modals: document.querySelectorAll('[id^="bizppurio_tpl_modal_body"]').length };
});
evidence('6a 저장 직전 전역 상태', beforeSave);
const [resp] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/admin/templates') && ['POST', 'PUT'].includes(r.request().method()), { timeout: 20_000 }),
modalSave(page).first().click(),
]);
evidence('6a 저장 응답', resp.status());
expect(resp.status()).toBeLessThan(400);
// ① 서버에 그대로 남았는가 (영속성)
const stored = await page.evaluate(async (id) => {
const r = await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${id}`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
});
const j = await r.json();
return j?.data?.template?.content?.templateContent ?? null;
}, targetId);
evidence('6a 저장 후 서버 본문 일치', { equal: stored === body, len: (stored ?? '').length });
expect(stored, '이모지·특수문자·#{var} 가 섞인 본문이 그대로 저장되어야 한다').toBe(body);
// ② 새로고침 후 화면이 그 값을 복원하는가 (영속성 UI 축)
modal = await openEditModal(page);
const reopenedId = await openedId();
const restored = await modal.locator('textarea[name="bz_template_content"]').inputValue();
const expected = reopenedId === targetId
? body
: await page.evaluate(async (id) => {
const r = await fetch(`/api/plugins/sirsoft-message_bizppurio/admin/templates/${id}`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
});
const j = await r.json();
return j?.data?.template?.content?.templateContent ?? null;
}, reopenedId);
evidence('6a 새로고침 후 복원', { targetId, reopenedId, equal: restored === expected, len: restored.length });
expect(restored, '새로고침 후 모달이 그 행의 저장값을 그대로 복원해야 한다').toBe(expected);
});
test('7a·7b 탭 왕복 시 목록 상태 유지 + 모달 상태 이월 0', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
const before = new URL(page.url()).search;
// 메일 탭 → 비즈뿌리오 복귀
const mail = page.locator('#channel_sub_tabs button', { hasText: '메일' }).first();
if (await mail.count() > 0) {
await mail.click();
await page.waitForTimeout(800);
await page.locator('#channel_sub_tabs button', { hasText: '비즈뿌리오' }).first().click();
await page.waitForTimeout(800);
}
const after = new URL(page.url()).search;
evidence('7a 탭 왕복 전/후 쿼리', { before, after });
expect(after).toContain('channel=alimtalk');
// 7b: 모달에 값을 넣고 저장 없이 닫은 뒤 다시 열면 이전 입력이 남지 않는다
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
await expect(modal).toBeVisible({ timeout: 15_000 });
await modal.locator('textarea[name="bz_template_content"]').fill('이월되면 안 되는 값');
await dialog(page).getByRole('button', { name: '취소', exact: true }).first().click();
await expect(modal).toBeHidden({ timeout: 10_000 });
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
await expect(modal).toBeVisible({ timeout: 15_000 });
const reopened = await modal.locator('textarea[name="bz_template_content"]').inputValue();
evidence('7b 재오픈 시 이전 값 잔존', reopened.includes('이월되면 안 되는 값'));
expect(reopened).not.toContain('이월되면 안 되는 값');
});
test('7c 관리 화면 필터+페이지 상태가 URL 에 보존된다 (mergeQuery)', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(`${MANAGE_URL}&bz_status=draft&bz_search=%EA%B0%80`);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await expect(page.locator('#templates_tab_panel')).toBeVisible({ timeout: 20_000 });
const q = new URL(page.url()).searchParams;
evidence('7c 관리 화면 URL 파라미터', {
tab: q.get('tab'), bz_status: q.get('bz_status'), bz_search: q.get('bz_search'),
});
expect(q.get('tab')).toBe('templates');
expect(q.get('bz_status')).toBe('draft');
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T8 3면 패리티·다크·로케일', () => {
for (const [face, url] of [['게시판', BOARD_URL], ['이커머스', ECOMMERCE_URL]] as const) {
test(`8a·8b ${face} 알림 설정에서 통합 탭·행 하단이 동일하게 렌더된다`, async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await page.goto(url);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const barId = url.includes('/boards/') ? '#board_channel_sub_tabs' : '#ecommerce_channel_sub_tabs';
const bar = page.locator(barId);
const visible = await bar.isVisible().catch(() => false);
const tabs = visible ? (await bar.locator('button').allInnerTexts()).map((t) => t.trim()) : [];
const rows = await page.locator('[id^="bizppurio_row_lifecycle"]').count();
evidence(`8 ${face} 탭/행`, { visible, tabs, rows, errors: errors.length });
// 면이 존재하면 통합 탭 어휘가 있어야 하고, 개별 채널 탭은 없어야 한다.
if (visible && tabs.length > 0) {
expect(tabs.join('|')).not.toContain('알림톡');
}
expect(errors).toHaveLength(0);
});
}
test('8d·8e 다크 모드 · 영어 로케일에서 원문 키 노출 0', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.emulateMedia({ colorScheme: 'dark' });
await openBizppurioTab(page);
const darkRaw = ((await page.locator('#notif_channel_content').innerText())
.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length;
evidence('8d 다크 모드 원문 키', darkRaw);
expect(darkRaw).toBe(0);
await page.evaluate(() => localStorage.setItem('g7_locale', 'en'));
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const enText = await page.locator('#notif_channel_content').innerText();
const enRaw = (enText.match(/\$t:|sirsoft-message_bizppurio\./g) ?? []).length;
evidence('8e 영어 로케일 원문 키', enRaw);
expect(enRaw).toBe(0);
await page.evaluate(() => localStorage.setItem('g7_locale', 'ko'));
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('T9 권한 분기', () => {
test('9a view 권한만으로는 작성·신청 조작이 불가하다', async ({ page, messagingViewToken }) => {
await authenticatePage(page, messagingViewToken);
await page.goto(MANAGE_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const composeCount = await page.getByRole('button', { name: '작성', exact: true }).count();
const editCount = await page.getByRole('button', { name: '수정', exact: true }).count();
const status = await page.evaluate(async () => {
const r = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}`,
},
body: JSON.stringify({ notification_type: 'welcome' }),
});
return r.status;
});
evidence('9a view 전용 — 버튼/POST', { composeCount, editCount, postStatus: status });
expect(status).toBe(403);
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('라운드5 신규 축 — 업로드 가드·관리 라벨·SMS 언어 탭', () => {
test('U1 업로드 진행 중 저장 계열 버튼이 잠기고 취소는 열려 있다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
await page.getByRole('button', { name: '알림톡 템플릿 작성' }).first().click();
const modal = dialog(page).locator('[id^="bizppurio_tpl_modal_body"]').first();
await expect(modal).toBeVisible({ timeout: 15_000 });
await modal.getByRole('button', { name: '이미지', exact: true }).click();
// 업로드 응답을 붙잡아 in-flight 상태를 만든다(네트워크 지연 재현).
let releaseUpload: () => void = () => {};
const held = new Promise<void>((r) => { releaseUpload = r; });
await page.route('**/admin/templates/image', async (route) => {
await held;
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, data: { url: 'https://mud-kage.kakao.com/x.png' } }) });
});
await modal.locator('input[type="file"]').setInputFiles({
name: 'a.png', mimeType: 'image/png', buffer: Buffer.from('89504e470d0a1a0a', 'hex'),
});
await page.waitForTimeout(1_200);
const save = modalSave(page).first();
const cancel = dialog(page).getByRole('button', { name: '취소', exact: true }).first();
const measured = { saveDisabled: await save.isDisabled(), cancelDisabled: await cancel.isDisabled() };
evidence('U1 업로드 중 버튼 상태', measured);
expect(measured.saveDisabled).toBe(true);
expect(measured.cancelDisabled).toBe(false);
releaseUpload();
await expect(save).toBeEnabled({ timeout: 15_000 });
evidence('U1 업로드 완료 후 저장 버튼', { saveDisabled: await save.isDisabled() });
});
test('R5 관리 화면 라벨이 내용 유무로 갈린다 + SMS 언어 탭이 존재한다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(MANAGE_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await expect(page.locator('#templates_tab_panel')).toBeVisible({ timeout: 20_000 });
// 관리 목록은 readiness(카카오 API 키 + 발신프로필) 충족 시에만 렌더된다 — 두 블록은 배타다.
const gate = {
readiness: await page.locator('#templates_readiness').count(),
manageView: await page.locator('#templates_manage_view').count(),
};
evidence('R5 readiness 게이트', gate);
expect(gate.readiness + gate.manageView, 'readiness 안내와 목록은 정확히 하나만 보여야 한다').toBe(1);
if (gate.manageView === 0) {
evidence('R5 라벨 측정', '(readiness 미충족 — 목록 미렌더)');
return;
}
// 목록 행 수를 API 로 먼저 잰다 — "행이 없어서 0" 과 "라벨 분기가 죽어서 0" 은 다른 판정이다.
const total = await page.evaluate(async () => {
const r = await fetch('/api/plugins/sirsoft-message_bizppurio/admin/templates?per_page=20', {
headers: { Accept: 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token') ?? ''}` },
});
const j = await r.json();
return j?.data?.pagination?.total ?? (j?.data?.templates?.length ?? 0);
});
const compose = await page.getByRole('button', { name: '작성', exact: true }).count();
const edit = await page.getByRole('button', { name: '수정', exact: true }).count();
evidence('R5 관리 화면 행 수/버튼 수', { total, compose, edit });
if (total > 0) {
expect(compose + edit, '행이 있는데 작성·수정 라벨이 하나도 없으면 라벨 분기가 죽은 것').toBeGreaterThan(0);
}
// SMS 본문 모달 → 언어 탭
const smsBtn = page.getByRole('button', { name: 'SMS 본문', exact: false }).first();
if (await smsBtn.count() > 0) {
await smsBtn.click();
const tabs = page.locator('#bz_sms_lang_tabs');
await expect(tabs).toBeVisible({ timeout: 15_000 });
const langs = (await tabs.locator('button').allInnerTexts()).map((t) => t.trim());
evidence('R5 SMS 언어 탭', langs);
expect(langs.length).toBeGreaterThan(0);
} else {
evidence('R5 SMS 언어 탭', '(SMS 본문 버튼 없음 — 행 없음)');
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
test.describe('회귀 축', () => {
test('R1·R2 메일/데이터베이스 탭과 채널 토글 카드가 정상 렌더된다', async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await openBizppurioTab(page);
const cards = await page.locator('#channel_toggles [role="switch"]').count();
evidence('R2 채널 토글 카드 수', cards);
expect(cards).toBeGreaterThanOrEqual(4);
for (const label of ['메일', '데이터베이스']) {
const tab = page.locator('#channel_sub_tabs button', { hasText: label }).first();
if (await tab.count() === 0) continue;
await tab.click();
await page.waitForTimeout(1_000);
const rendered = await page.locator('#notif_channel_content').count();
evidence(`R1 ${label} 탭 콘텐츠`, rendered);
expect(rendered).toBeGreaterThan(0);
}
evidence('R1 콘솔 에러 수', errors.length);
expect(errors).toHaveLength(0);
});
test('R3·R4 플러그인 연동 설정·발송 이력 탭이 정상 렌더된다', async ({ page, messagingManageToken }) => {
const errors = collectConsoleErrors(page);
await authenticatePage(page, messagingManageToken);
await page.goto('/admin/plugins/sirsoft-message_bizppurio/settings');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const tabs = (await page.locator('button').allInnerTexts()).map((t) => t.trim());
evidence('R3·R4 플러그인 설정 탭', tabs.filter((t) => t && t.length < 20).slice(0, 12));
evidence('R3·R4 콘솔 에러 수', errors.length);
expect(errors).toHaveLength(0);
});
test('R6 사용자(basic) 화면이 영향을 받지 않는다', async ({ page }) => {
const errors = collectConsoleErrors(page);
await page.goto('/');
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const title = await page.title();
evidence('R6 사용자 화면', { title, errors: errors.length, messages: errors.slice(0, 5) });
// 에러 원문을 남긴다 — 개편과 무관한 사전존재 에러와 회귀를 구분하는 근거가 된다.
expect(errors.filter((e) => /bizppurio/i.test(e)),
'사용자 화면에 비즈뿌리오 관련 에러가 있으면 회귀다').toHaveLength(0);
});
});
@@ -0,0 +1,163 @@
/**
* E2E: 비즈뿌리오 알림 템플릿 라이프사이클 화면 (#597)
*
* @scenario bizppurio_tab_integration_e2e, bizppurio_compose_modal_e2e, bizppurio_manage_round_trip_e2e
* @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_badge_and_lifecycle_actions,
* compose_modal_switches_conditional_fields_by_type, manage_screen_lists_db_rows_with_merge_query_round_trip
*
* 검증 대상(브라우저에서만 잡히는 축):
* 1. 코어 알림 설정 서브탭 통합 — '비즈뿌리오' 단일 탭 노출, 문자·알림톡 개별 탭 부재
* (탭 필터 표현식 3면 수정의 소비처 실렌더)
* 2. 행 하단 라이프사이클 UI — 상태 배지 + 작성 버튼 + SMS 체크박스 렌더, i18n 원문 노출 0
* 3. 작성 모달 — 강조 유형 전환(없음→강조표기→이미지→아이템리스트) 시 조건부 필드 등장/소멸
* 4. 플러그인 관리 화면 — 상태 필터가 URL(bz_status)에 보존되는 목록 왕복(mergeQuery)
*
* 카카오 API(kapi) 실호출이 필요한 검수 신청·동기화 경로는 브라우저 E2E 범위 밖이다
* (자격증명·실검수 필요 — PHPUnit 이 Http::fake 로 전수 가드, 실연동은 PO 수행 단계).
*
* 계획서 §6.2 의 "작성→신청→(kapi mock 승인)→발송 배지" 왕복 축을 여기에 두지 않은 이유는
* 구조적이다: kapi 호출은 서버(PHP)에서 서버로 나가고, Playwright 의 route 가로채기는
* 브라우저→서버 구간만 덮는다. 따라서 브라우저 경로에서는 승인 전이를 mock 으로 만들 수
* 없고, approved 상태를 만들려면 DB 를 직접 조작해야 하는데 그것은 화면 왕복 검증이 아니다.
* 승인 전이와 발송 게이트는 PHPUnit(BizppurioTemplateServiceTest 의 sync 전이,
* AlimtalkChannelDriverTest 의 게이트 5분기)이 소유하고, 브라우저는 그 결과를 그리는
* 배지·액션 분기(위 2번 축)만 담당한다.
*/
import { test, expect, authenticatePage } from '../../fixtures/bizppurio-auth';
const SETTINGS_URL = '/admin/settings?tab=notification_definitions&channel=alimtalk';
const MANAGE_URL = '/admin/plugins/sirsoft-message_bizppurio/settings?tab=templates';
test.describe('비즈뿌리오 통합 탭 (코어 알림 설정)', () => {
// @scenario bizppurio_tab_integration_e2e
// @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
test('채널 활성 저장 시 서브탭에 비즈뿌리오 단일 탭이 노출되고 문자·알림톡 개별 탭은 없다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const tabBar = page.locator('#channel_sub_tabs');
await expect(tabBar).toBeVisible({ timeout: 30_000 });
// 확장 채널은 opt-in(미저장=OFF)이라 통합 탭은 tab_channels 중 하나라도 활성 저장돼야
// 노출된다. 미노출 상태면 알림톡 토글 카드를 켜고 저장해 활성화한다(멱등 — 이미 켜져
// 있으면 이 분기를 타지 않는다).
if (!(await tabBar.innerText()).includes('비즈뿌리오')) {
const card = page
.locator('#channel_toggles')
.locator('div.flex-between', { hasText: '비즈뿌리오 알림톡' })
.first();
await expect(card).toBeVisible({ timeout: 15_000 });
await card.locator('[role="switch"]').click();
await page.locator('#save_button').click();
await page.waitForLoadState('networkidle', { timeout: 30_000 });
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
}
const tabTexts = await tabBar.locator('button').allInnerTexts();
const joined = tabTexts.join('|');
// 통합 탭 라벨(tab_label_key) 노출 + 채널 개별 라벨 부재
expect(joined).toContain('비즈뿌리오');
expect(joined).not.toContain('비즈뿌리오 문자');
expect(joined).not.toContain('비즈뿌리오 알림톡');
});
// @scenario bizppurio_tab_integration_e2e
// @effects row_footer_shows_status_badge_and_lifecycle_actions
test('알림 행 하단에 알림톡 라이프사이클 줄과 SMS 설정 줄이 렌더된다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const rows = page.locator('[id^="bizppurio_row_lifecycle"]');
await expect(rows.first()).toBeVisible({ timeout: 30_000 });
// 알림톡 라벨 + 상태 배지(미작성/작성중/…) + SMS 체크박스 라벨
const firstRow = rows.first();
await expect(firstRow.getByText('알림톡', { exact: false }).first()).toBeVisible();
await expect(firstRow.getByText('대체 SMS', { exact: false }).first()).toBeVisible();
await expect(firstRow.getByText('SMS 단독', { exact: false }).first()).toBeVisible();
// i18n 원문 키 노출 0 (탭 콘텐츠 영역 전체)
const content = await page.locator('#notif_channel_content').innerText();
expect(content).not.toContain('$t:');
expect(content).not.toContain('sirsoft-message_bizppurio.');
});
// @scenario bizppurio_compose_modal_e2e
// @effects compose_modal_switches_conditional_fields_by_type
test('작성 모달의 강조 유형 전환 시 조건부 필드가 등장·소멸한다', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(SETTINGS_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
// 미작성 행의 [알림톡 템플릿 작성] → 모달 오픈
const composeButton = page.getByRole('button', { name: '알림톡 템플릿 작성' }).first();
await expect(composeButton).toBeVisible({ timeout: 30_000 });
await composeButton.click();
const modal = page.locator('[id^="bizppurio_tpl_modal_body"]').first();
await expect(modal).toBeVisible({ timeout: 15_000 });
// 본문 textarea 는 상시
await expect(modal.locator('textarea[name="bz_template_content"]')).toBeVisible();
// 없음(NONE): 강조표기·이미지·아이템 필드 부재
await expect(modal.getByText('강조표기 타이틀', { exact: false })).toHaveCount(0);
// 강조표기(TEXT): 타이틀·서브타이틀 등장
await modal.getByRole('button', { name: '강조표기', exact: true }).click();
await expect(modal.getByText('강조표기 타이틀', { exact: false }).first()).toBeVisible();
// 이미지(IMAGE): 파일 입력 등장, TEXT 필드 소멸
await modal.getByRole('button', { name: '이미지', exact: true }).click();
await expect(modal.locator('input[type="file"]')).toBeVisible();
await expect(modal.getByText('강조표기 타이틀', { exact: false })).toHaveCount(0);
// 아이템리스트(ITEM_LIST): 아이템 추가 버튼 등장
await modal.getByRole('button', { name: '아이템리스트', exact: true }).click();
await expect(modal.getByRole('button', { name: '아이템 추가' })).toBeVisible();
await expect(modal.locator('input[type="file"]')).toHaveCount(0);
// 버튼 추가 — 1행 추가 후 linkType 셀렉트·이름 입력 등장
await modal.getByRole('button', { name: '버튼 추가' }).click();
await expect(modal.getByPlaceholder('버튼명', { exact: false }).first()).toBeVisible();
});
});
test.describe('비즈뿌리오 알림 템플릿 관리 (플러그인 설정)', () => {
// @scenario bizppurio_manage_round_trip_e2e
// @effects manage_screen_lists_db_rows_with_merge_query_round_trip
test('상태 필터가 URL 에 보존되고 목록이 렌더된다 (mergeQuery 왕복)', async ({ page, messagingManageToken }) => {
await authenticatePage(page, messagingManageToken);
await page.goto(MANAGE_URL);
await page.waitForLoadState('networkidle', { timeout: 30_000 });
const manageView = page.locator('#templates_manage_view');
const readiness = page.locator('#templates_readiness');
// 자격증명 미설정 환경이면 readiness 안내가 정상 경로다 — 관리 뷰 검증은 ready 환경에서만.
if (await readiness.isVisible().catch(() => false)) {
await expect(readiness.getByText('환경설정', { exact: false }).first()).toBeVisible();
return;
}
await expect(manageView).toBeVisible({ timeout: 30_000 });
// 상태 필터 변경 → URL bz_status 반영 (mergeQuery — tab 파라미터 유지)
const filterRoot = manageView.locator('[aria-haspopup]').first();
await filterRoot.click();
await page.getByRole('option', { name: '승인됨' }).click();
await expect(page).toHaveURL(/bz_status=approved/);
await expect(page).toHaveURL(/tab=templates/);
// i18n 원문 노출 0
const text = await manageView.innerText();
expect(text).not.toContain('$t:');
expect(text).not.toContain('sirsoft-message_bizppurio.');
});
});
@@ -0,0 +1,175 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Enums;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* BizppurioTemplateStatus — kapi serviceStatus 환원 매핑과 상태 판정 헬퍼 검증 (#597 §3.4).
*/
class BizppurioTemplateStatusTest extends PluginTestCase
{
/**
* kapi serviceStatus 코드 → 우리 상태 매핑 전수 검증.
*/
/**
* @effects status_enum_maps_kapi_service_status_vocabulary
*/
public function test_service_status_매핑_전수(): void
{
$this->assertSame(BizppurioTemplateStatus::Draft, BizppurioTemplateStatus::tryFromServiceStatus('REG'));
$this->assertSame(BizppurioTemplateStatus::Requested, BizppurioTemplateStatus::tryFromServiceStatus('REQ'));
$this->assertSame(BizppurioTemplateStatus::Rejected, BizppurioTemplateStatus::tryFromServiceStatus('REJ'));
$this->assertSame(BizppurioTemplateStatus::Approved, BizppurioTemplateStatus::tryFromServiceStatus('RDY'));
$this->assertSame(BizppurioTemplateStatus::Approved, BizppurioTemplateStatus::tryFromServiceStatus('ACT'));
$this->assertSame(BizppurioTemplateStatus::Stopped, BizppurioTemplateStatus::tryFromServiceStatus('STP'));
$this->assertSame(BizppurioTemplateStatus::Blocked, BizppurioTemplateStatus::tryFromServiceStatus('BLK'));
$this->assertSame(BizppurioTemplateStatus::Dormant, BizppurioTemplateStatus::tryFromServiceStatus('DMT'));
}
/**
* 알 수 없는 코드는 null — 동기화 호출측이 상태를 덮어쓰지 않고 건너뛴다.
*/
/**
* @effects unknown_service_status_does_not_overwrite_state
*/
public function test_미지의_service_status는_null(): void
{
$this->assertNull(BizppurioTemplateStatus::tryFromServiceStatus('XX'));
}
/**
* serviceStatus 필드가 있으면 유도 없이 그대로 사용한다.
*/
public function test_상세_유도는_service_status_필드를_우선한다(): void
{
$this->assertSame('ACT', BizppurioTemplateStatus::serviceStatusFromDetail([
'serviceStatus' => 'ACT',
// 아래 필드는 무시되어야 한다 (serviceStatus 우선).
'inspectionStatus' => 'REJ',
'status' => 'S',
]));
}
/**
* block 플래그는 최우선으로 BLK 를 유도한다.
*/
public function test_상세_유도_block은_blk(): void
{
$this->assertSame('BLK', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'APR',
'status' => 'A',
'block' => true,
]));
}
/**
* dormant 플래그는 DMT 를 유도한다.
*/
public function test_상세_유도_dormant는_dmt(): void
{
$this->assertSame('DMT', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'APR',
'status' => 'A',
'dormant' => true,
]));
}
/**
* inspectionStatus REQ(검수중)는 REQ 를 유도한다.
*/
public function test_상세_유도_inspection_re_q는_req(): void
{
$this->assertSame('REQ', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'REQ',
'status' => 'R',
]));
}
/**
* inspectionStatus REJ(반려)는 REJ 를 유도한다.
*/
public function test_상세_유도_inspection_re_j는_rej(): void
{
$this->assertSame('REJ', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'REJ',
'status' => 'R',
]));
}
/**
* 승인(APR) + status S(중지)는 STP 를 유도한다.
*/
public function test_상세_유도_ap_r_상태_s는_stp(): void
{
$this->assertSame('STP', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'APR',
'status' => 'S',
]));
}
/**
* 승인(APR) + status A(정상)는 ACT 를 유도한다.
*/
public function test_상세_유도_ap_r_상태_a는_act(): void
{
$this->assertSame('ACT', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'APR',
'status' => 'A',
]));
}
/**
* 승인(APR) + status R(발송전)은 RDY 를 유도한다.
*/
public function test_상세_유도_ap_r_상태_r은_rdy(): void
{
$this->assertSame('RDY', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'APR',
'status' => 'R',
]));
}
/**
* 판정 조건이 하나도 성립하지 않으면 기본값 REG(등록)를 유도한다.
*/
public function test_상세_유도_기본값은_reg(): void
{
$this->assertSame('REG', BizppurioTemplateStatus::serviceStatusFromDetail([
'inspectionStatus' => 'REG',
'status' => 'R',
]));
$this->assertSame('REG', BizppurioTemplateStatus::serviceStatusFromDetail([]));
}
/**
* isApproved 는 Approved 상태만 true 다 (발송 게이트 근거).
*/
public function test_is_approved는_승인만_true(): void
{
$this->assertTrue(BizppurioTemplateStatus::Approved->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Draft->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Requested->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Rejected->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Stopped->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Blocked->isApproved());
$this->assertFalse(BizppurioTemplateStatus::Dormant->isApproved());
}
/**
* allowsContentEdit 는 Draft·Rejected 만 true 다 (kapi update/delete 허용 상태, 부록 A-4).
*/
public function test_allows_content_edit는_draft와_rejected만_true(): void
{
$this->assertTrue(BizppurioTemplateStatus::Draft->allowsContentEdit());
$this->assertTrue(BizppurioTemplateStatus::Rejected->allowsContentEdit());
$this->assertFalse(BizppurioTemplateStatus::Requested->allowsContentEdit());
$this->assertFalse(BizppurioTemplateStatus::Approved->allowsContentEdit());
$this->assertFalse(BizppurioTemplateStatus::Stopped->allowsContentEdit());
$this->assertFalse(BizppurioTemplateStatus::Blocked->allowsContentEdit());
$this->assertFalse(BizppurioTemplateStatus::Dormant->allowsContentEdit());
}
}
@@ -94,6 +94,31 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase
$this->assertFalse($alimtalk['is_test_mode'], '운영 모드면 is_test_mode=false.');
}
public function test_채널메타에_비즈뿌리오_통합_탭_필드가_실린다(): void
{
// #597 §3.3 '비즈뿌리오' 통합 탭: sms 는 탭만 숨기고(hidden_tab — 채널 토글 카드·발송
// 축은 유지), alimtalk 이 두 채널을 묶은 통합 탭을 대표한다(tab_channels/tab_label_key).
// 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다.
$channels = $this->makeListener()->addChannels([]);
$sms = collect($channels)->firstWhere('id', 'sms');
$this->assertTrue($sms['hidden_tab'], 'sms 는 자체 탭을 숨겨야 한다(hidden_tab:true).');
$this->assertArrayNotHasKey('tab_channels', $sms, '통합 탭 대표는 alimtalk 하나뿐이어야 한다.');
$alimtalk = collect($channels)->firstWhere('id', 'alimtalk');
$this->assertArrayNotHasKey('hidden_tab', $alimtalk, 'alimtalk 탭은 숨기지 않는다(통합 탭 대표).');
$this->assertSame(
['sms', 'alimtalk'],
$alimtalk['tab_channels'],
'tab_channels 중 하나라도 활성 저장이면 통합 탭을 노출한다.'
);
$this->assertSame(
'sirsoft-message_bizppurio.channels.bizppurio_tab',
$alimtalk['tab_label_key'],
'탭 라벨은 프론트 lang 키($t 해석)로 선언한다(카드 라벨 name_key 와 분리).'
);
}
public function test_어떤_채널에도_uses_custom_list_플래그가_없다(): void
{
// Phase 6 재설계 A(⚑⚑ 결정 A): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을
@@ -0,0 +1,208 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Models;
use Carbon\Carbon;
use Illuminate\Database\QueryException;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* BizppurioTemplate 모델 — 캐스트·알림톡 발송 게이트·unique 제약 검증 (#597).
*/
class BizppurioTemplateTest extends PluginTestCase
{
/**
* 기본 필드가 채워진 템플릿 행을 생성한다.
*
* @param array<string, mixed> $overrides
*/
private function makeTemplate(array $overrides = []): BizppurioTemplate
{
return BizppurioTemplate::create(array_merge([
'notification_type' => 'order.completed.'.uniqid(),
'alimtalk_enabled' => true,
'template_code' => 'TW_'.uniqid(),
'sender_key' => 'SK_TEST',
'content' => ['templateName' => '주문완료', 'templateContent' => '#{name}님 주문이 완료되었습니다.'],
'approved_content' => ['templateName' => '주문완료', 'templateContent' => '#{name}님 주문이 완료되었습니다.'],
'status' => BizppurioTemplateStatus::Approved->value,
'is_active' => true,
], $overrides));
}
/**
* JSON 컬럼 3종(content/approved_content/inspection_detail)이 배열로 저장·조회된다.
*/
/**
* @effects model_casts_content_snapshot_inspection_json
*/
public function test_json_컬럼은_배열로_저장되고_조회된다(): void
{
$template = $this->makeTemplate([
'content' => ['templateName' => 'T', 'buttons' => [['name' => '바로가기']]],
'approved_content' => ['templateName' => 'T승인'],
'inspection_detail' => [['content' => '반려 사유 원문']],
]);
$fresh = BizppurioTemplate::query()->find($template->id);
$this->assertSame('T', $fresh->content['templateName']);
$this->assertSame('바로가기', $fresh->content['buttons'][0]['name']);
$this->assertSame(['templateName' => 'T승인'], $fresh->approved_content);
$this->assertSame([['content' => '반려 사유 원문']], $fresh->inspection_detail);
}
/**
* status 컬럼은 BizppurioTemplateStatus enum 으로 캐스트된다.
*/
public function test_status는_enum으로_캐스트된다(): void
{
$template = $this->makeTemplate(['status' => 'requested']);
$fresh = BizppurioTemplate::query()->find($template->id);
$this->assertSame(BizppurioTemplateStatus::Requested, $fresh->status);
}
/**
* boolean 4종(alimtalk_enabled/fallback_sms_enabled/sms_only/is_active)이 캐스트된다.
*/
public function test_boolean_4종_캐스트(): void
{
$template = $this->makeTemplate([
'alimtalk_enabled' => 1,
'fallback_sms_enabled' => 1,
'sms_only' => 0,
'is_active' => 0,
]);
$fresh = BizppurioTemplate::query()->find($template->id);
$this->assertTrue($fresh->alimtalk_enabled);
$this->assertTrue($fresh->fallback_sms_enabled);
$this->assertFalse($fresh->sms_only);
$this->assertFalse($fresh->is_active);
}
/**
* datetime 3종(requested_at/approved_at/last_synced_at)이 Carbon 으로 캐스트된다.
*/
public function test_datetime_3종_캐스트(): void
{
$template = $this->makeTemplate([
'requested_at' => '2026-08-01 09:00:00',
'approved_at' => '2026-08-02 10:30:00',
'last_synced_at' => '2026-08-03 11:45:00',
]);
$fresh = BizppurioTemplate::query()->find($template->id);
$this->assertInstanceOf(Carbon::class, $fresh->requested_at);
$this->assertInstanceOf(Carbon::class, $fresh->approved_at);
$this->assertInstanceOf(Carbon::class, $fresh->last_synced_at);
$this->assertSame('2026-08-01 09:00:00', $fresh->requested_at->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-02 10:30:00', $fresh->approved_at->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-03 11:45:00', $fresh->last_synced_at->format('Y-m-d H:i:s'));
}
/**
* 발송 게이트: 승인 + 활성 + 알림톡 사용 + 승인 스냅샷 존재 → 발송 가능.
*/
public function test_알림톡_발송_게이트_모든_조건_충족시_true(): void
{
$this->assertTrue($this->makeTemplate()->isAlimtalkSendable());
}
/**
* 발송 게이트: status 가 draft(승인 취소 복귀 포함)면 승인 스냅샷이 남아 있어도 차단.
*/
public function test_알림톡_발송_게이트_draft_상태면_false(): void
{
$template = $this->makeTemplate(['status' => BizppurioTemplateStatus::Draft->value]);
$this->assertFalse($template->isAlimtalkSendable());
}
/**
* 발송 게이트: alimtalk_enabled 가 꺼져 있으면 차단.
*/
public function test_알림톡_발송_게이트_alimtalk_enabled_false면_false(): void
{
$template = $this->makeTemplate(['alimtalk_enabled' => false]);
$this->assertFalse($template->isAlimtalkSendable());
}
/**
* 발송 게이트: is_active 가 꺼져 있으면 차단.
*/
public function test_알림톡_발송_게이트_is_active_false면_false(): void
{
$template = $this->makeTemplate(['is_active' => false]);
$this->assertFalse($template->isAlimtalkSendable());
}
/**
* 발송 게이트: 승인 스냅샷(approved_content)이 null 이면 차단.
*/
public function test_알림톡_발송_게이트_approved_content_null이면_false(): void
{
$template = $this->makeTemplate(['approved_content' => null]);
$this->assertFalse($template->isAlimtalkSendable());
}
/**
* 발송 게이트: 승인 스냅샷이 빈 배열이어도 차단 (내용 없는 스냅샷으로는 발송 불가).
*/
public function test_알림톡_발송_게이트_approved_content_빈배열이면_false(): void
{
$template = $this->makeTemplate(['approved_content' => []]);
$this->assertFalse($template->isAlimtalkSendable());
}
/**
* notification_type 은 unique — 알림 1건당 1행 원칙을 DB 가 강제한다.
*/
public function test_notification_type_중복_생성시_query_exception(): void
{
$this->makeTemplate(['notification_type' => 'order.completed.dup']);
$this->expectException(QueryException::class);
$this->makeTemplate(['notification_type' => 'order.completed.dup']);
}
public function test_sms_단독이면_승인된_알림톡도_발송하지_않는다(): void
{
// "SMS 단독" 은 운영자가 이 알림에서 알림톡을 쓰지 않겠다고 명시한 것이다(#597 §3.5).
$template = BizppurioTemplate::create([
'notification_type' => 'welcome',
'alimtalk_enabled' => true,
'is_active' => true,
'sms_only' => true,
'status' => BizppurioTemplateStatus::Approved->value,
'approved_content' => ['templateContent' => '승인 본문'],
]);
$this->assertFalse($template->isAlimtalkSendable());
}
public function test_sms_단독이_아니면_승인된_알림톡은_발송한다(): void
{
$template = BizppurioTemplate::create([
'notification_type' => 'welcome',
'alimtalk_enabled' => true,
'is_active' => true,
'sms_only' => false,
'status' => BizppurioTemplateStatus::Approved->value,
'approved_content' => ['templateContent' => '승인 본문'],
]);
$this->assertTrue($template->isAlimtalkSendable());
}
}
@@ -1,67 +0,0 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Repositories;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* BizppurioNotificationBindingRepository — upsert·findActive·delete 검증.
*/
class BizppurioNotificationBindingRepositoryTest extends PluginTestCase
{
private BizppurioNotificationBindingRepository $repo;
protected function setUp(): void
{
parent::setUp();
$this->repo = new BizppurioNotificationBindingRepository;
}
public function test_upsert_creates_then_updates(): void
{
$created = $this->repo->upsert('welcome', 'alimtalk', [
'template_code' => 'TW_1',
'template_name' => '가입환영',
'fallback_sms_enabled' => true,
]);
$this->assertSame('TW_1', $created->template_code);
// 같은 (type, channel) 재저장 → 갱신(중복 생성 아님)
$updated = $this->repo->upsert('welcome', 'alimtalk', [
'template_code' => 'TW_2',
'template_name' => '가입환영v2',
'fallback_sms_enabled' => false,
]);
$this->assertSame($created->id, $updated->id);
$this->assertSame('TW_2', $updated->template_code);
$this->assertDatabaseCount('bizppurio_notification_bindings', 1);
}
public function test_find_active_only_returns_active(): void
{
$this->repo->upsert('welcome', 'alimtalk', [
'template_code' => 'TW_1',
'template_name' => 'n',
'is_active' => true,
]);
$this->assertNotNull($this->repo->findActive('welcome'));
$this->repo->upsert('welcome', 'alimtalk', ['is_active' => false, 'template_code' => 'TW_1', 'template_name' => 'n']);
$this->assertNull($this->repo->findActive('welcome'));
}
public function test_delete(): void
{
$this->repo->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_9',
'template_name' => '주문완료',
]);
$this->repo->delete('order_confirmed', 'alimtalk');
$this->assertNull($this->repo->findActive('order_confirmed'));
$this->assertDatabaseCount('bizppurio_notification_bindings', 0);
}
}
@@ -2,126 +2,84 @@
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Models\NotificationTemplate;
use App\Models\NotificationDefinition;
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 Illuminate\Support\Facades\Http;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* AlimtalkChannelDriver — binding 게이트·변수치환({x}→#{x})·대체발송·Job 위임 검증.
* AlimtalkChannelDriver — 승인 스냅샷 게이트·변수치환·대체발송·Job 위임 검증 (#597 §3.5).
*
* 알림톡은 SMS 와 달리 "관리자가 연결한 승인 템플릿(binding)"이 있을 때만 발송된다.
* 알림톡 본문·요소의 출처는 bizppurio_templates 의 approved_content(승인 스냅샷)다.
* 게이트 = alimtalk_enabled + is_active + status=approved + approved_content 존재.
* 발송 Job dispatch 는 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버).
*/
class AlimtalkChannelDriverTest extends PluginTestCase
{
/**
* 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다.
* 승인 스냅샷을 담은 템플릿 행을 만듭니다.
*
* @param array<string, string> $rendered replaceVariables 반환값
* @param array<string, mixed>|null $approved approved_content
*/
private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '{name} 님 주문이 완료되었습니다.']): NotificationTemplate
{
$template = Mockery::mock(NotificationTemplate::class)->makePartial();
$template->is_active = true;
$template->shouldReceive('replaceVariables')->andReturn($rendered);
private function templateRow(
?array $approved = null,
BizppurioTemplateStatus $status = BizppurioTemplateStatus::Approved,
bool $alimtalkEnabled = true,
bool $isActive = true,
bool $fallback = false,
string|array|null $smsBody = null,
): BizppurioTemplate {
$row = new BizppurioTemplate;
$row->notification_type = 'order_confirmed';
$row->alimtalk_enabled = $alimtalkEnabled;
$row->template_code = 'g7_abcd1234_1';
$row->status = $status;
$row->approved_content = $approved ?? ['templateContent' => '#{name}님 주문이 완료되었습니다.'];
$row->fallback_sms_enabled = $fallback;
// sms_body 는 로케일 맵이다(#597 §14.3). 문자열은 ko 본문으로 취급한다.
$row->sms_body = is_string($smsBody) ? ['ko' => $smsBody] : $smsBody;
$row->is_active = $isActive;
return $template;
return $row;
}
/**
* findActive 반환값을 지정한 binding 리포지토리 mock 을 만듭니다.
*
* @param BizppurioNotificationBinding|null $binding findActive 반환값
* findByType 반환값을 지정한 템플릿 리포지토리 mock 을 만듭니다.
*/
private function fakeBindings(?BizppurioNotificationBinding $binding): BizppurioNotificationBindingRepositoryInterface
private function fakeTemplates(?BizppurioTemplate $row): BizppurioTemplateRepositoryInterface
{
$repo = Mockery::mock(BizppurioNotificationBindingRepositoryInterface::class);
$repo->shouldReceive('findActive')
->with(Mockery::any(), 'alimtalk')
->andReturn($binding);
$repo = Mockery::mock(BizppurioTemplateRepositoryInterface::class);
$repo->shouldReceive('findByType')->andReturn($row);
return $repo;
}
/**
* 연결(binding) 모델을 만듭니다.
*/
private function binding(bool $fallback = false): BizppurioNotificationBinding
{
$binding = new BizppurioNotificationBinding;
$binding->notification_type = 'order_confirmed';
$binding->channel = 'alimtalk';
$binding->template_code = 'TW_1234';
$binding->template_name = '주문완료';
$binding->fallback_sms_enabled = $fallback;
$binding->is_active = true;
return $binding;
}
/**
* 카카오 상세조회 내용을 반환하는 KakaoTemplateContentResolver mock 을 만듭니다.
*
* @param array<string, mixed>|null $content resolve 반환값 (null=조회 실패 → skip)
*/
private function fakeKakaoContent(?array $content): KakaoTemplateContentResolver
{
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('resolve')->andReturn($content);
return $resolver;
}
/**
* 기본 카카오 상세(본문만 있는 단순 승인 템플릿).
*
* @return array<string, mixed>
*/
private function kakaoContent(string $templateContent = '#{name}님 주문이 완료되었습니다.'): array
{
return ['templateCode' => 'TW_1234', 'templateContent' => $templateContent];
}
/**
* 템플릿·binding·빌더를 조합한 driver 를 만듭니다.
*
* @param array<string, mixed>|null $kakao 카카오 상세조회 내용 (null=조회 실패)
* @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용)
* 드라이버를 조립합니다.
*/
private function makeDriver(
?NotificationTemplate $template,
?BizppurioNotificationBinding $binding,
?BizppurioTemplate $row,
?MessagePayloadBuilder $builder = null,
array|false|null $kakao = false,
bool $isTestMode = true,
): AlimtalkChannelDriver {
$templateService = Mockery::mock(NotificationTemplateService::class);
$templateService->shouldReceive('resolve')
->with(Mockery::any(), 'alimtalk')
->andReturn($template);
// $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)
@@ -132,13 +90,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$definitionService->shouldReceive('resolve')->andReturn(null);
return new AlimtalkChannelDriver(
$templateService,
$definitionService,
$this->fakeBindings($binding),
$this->fakeTemplates($row),
$builder ?? $this->spyBuilder(),
new BizppurioDispatchRepository,
new DispatchLinkContext,
$this->fakeKakaoContent($content),
new AlimtalkPayloadMapper,
$pluginSettings,
);
@@ -180,47 +136,163 @@ class AlimtalkChannelDriverTest extends PluginTestCase
return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce');
}
public function test_연결된_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void
/**
* @scenario template_state=row_missing, fallback_sms=off, sms_only=off, recipient=member
*
* @effects alimtalk_skipped_when_row_missing, no_kapi_call_at_dispatch_time
*/
public function test_템플릿_행이_없으면_예외를_던지고_발송하지_않는다(): void
{
Bus::fake();
Http::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver(null)->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
$this->assertDatabaseCount('bizppurio_dispatches', 0);
// 발송 판정은 DB 행만 본다 — 게이트 불충족 시에도 카카오 조회가 없어야 한다(§3.4).
Http::assertNothingSent();
}
}
/**
* @scenario template_state=unapproved, fallback_sms=off, sms_only=off, recipient=member
*
* @effects alimtalk_skipped_when_not_approved, no_kapi_call_at_dispatch_time
*/
public function test_미승인_상태면_예외를_던지고_발송하지_않는다(): void
{
Bus::fake();
Http::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// draft 상태 — 승인 스냅샷이 남아 있어도(승인취소 복귀) status 가 approved 가 아니면 차단.
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->templateRow(status: BizppurioTemplateStatus::Draft))
->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
Http::assertNothingSent();
}
}
/**
* @scenario template_state=disabled, fallback_sms=off, sms_only=off, recipient=member
*
* @effects alimtalk_skipped_when_alimtalk_disabled, no_kapi_call_at_dispatch_time
*/
public function test_알림톡_사용이_꺼져_있으면_발송하지_않는다(): void
{
Bus::fake();
Http::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->templateRow(alimtalkEnabled: false))
->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
Http::assertNothingSent();
}
}
/**
* @scenario template_state=disabled, fallback_sms=off, sms_only=off, recipient=member
*
* @effects alimtalk_skipped_when_row_inactive
*/
public function test_비활성_행이면_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// binding = null → 미연결 → NotificationSendSkippedException(코어가 실패로 기록하도록)
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification());
$this->makeDriver($this->templateRow(isActive: false))
->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
$this->assertDatabaseCount('bizppurio_dispatches', 0);
}
}
/**
* @scenario template_state=unapproved, fallback_sms=off, sms_only=off, recipient=member
*
* @effects alimtalk_skipped_when_snapshot_missing
*/
public function test_승인_스냅샷이_없으면_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$row = $this->templateRow();
$row->approved_content = null;
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($row)->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
}
/**
* @scenario template_state=approved, fallback_sms=off, sms_only=on, recipient=member
*
* @effects alimtalk_skipped_when_sms_only_selected, no_kapi_call_at_dispatch_time
*/
public function test_sms_단독을_선택한_알림은_승인되어도_알림톡을_발송하지_않는다(): void
{
Bus::fake();
Http::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$row = $this->templateRow();
$row->sms_only = true;
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($row)->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
Http::assertNothingSent();
}
}
/**
* @effects skip_exception_uses_human_readable_type_label
*/
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('회원가입 환영');
$definition = Mockery::mock(NotificationDefinition::class);
$definition->shouldReceive('getLocalizedName')->andReturn('주문 완료');
$definitionService = Mockery::mock(\App\Services\NotificationDefinitionService::class);
$definitionService = Mockery::mock(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 = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')->andReturn(true);
$driver = new AlimtalkChannelDriver(
$templateService,
$definitionService,
$this->fakeBindings(null),
$this->fakeTemplates(null),
$this->spyBuilder(),
new BizppurioDispatchRepository,
new DispatchLinkContext,
$this->fakeKakaoContent($this->kakaoContent()),
new AlimtalkPayloadMapper,
$pluginSettings,
);
@@ -229,74 +301,53 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$driver->send($member, $this->notification());
$this->fail('NotificationSendSkippedException 가 발생해야 한다.');
} catch (NotificationSendSkippedException $e) {
$this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.');
$this->assertStringContainsString('주문 완료', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.');
$this->assertStringNotContainsString('order_confirmed', $e->getMessage());
}
}
public function test_카카오_템플릿_내용_조회_실패시_예외를_던지고_발송하지_않는다(): void
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)
$this->makeDriver($this->templateRow(approved: ['templateContent' => ' ']))
->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
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// 알림톡 본문은 카카오에서 오므로, 코어 alimtalk 템플릿(SMS 대체용)이 없어도 발송돼야 한다.
// 대체발송 OFF 이면 코어 템플릿을 아예 조회하지 않는다.
$this->makeDriver(null, $this->binding(fallback: false))->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
}
public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void
/**
* @scenario template_state=approved, fallback_sms=off, sms_only=off, recipient=member
*
* @effects dispatch_uses_row_template_code, no_kapi_call_at_dispatch_time
*/
public function test_회원은_mobile로_자체_채번_템플릿코드로_발송한다(): void
{
Bus::fake();
Http::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$builder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
->send($member, $this->notification());
$this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
$this->assertCount(1, $builder->calls);
$this->assertSame('01012345678', $builder->calls[0]['to']);
$this->assertSame('TW_1234', $builder->calls[0]['templateCode'], '연결된 카카오 템플릿 코드로 발송해야 한다.');
$this->assertSame('g7_abcd1234_1', $builder->calls[0]['templateCode'], '행의 template_code 로 발송해야 한다.');
// 본문·버튼은 승인 스냅샷에서 온다 — 발송 시점에 카카오를 조회하지 않는다(§3.4).
Http::assertNothingSent();
}
/**
* @scenario template_state=approved, fallback_sms=off, sms_only=off, recipient=guest
*
* @effects guest_phone_resolved_from_data_recipient_phone
*/
public function test_비회원은_data의_전화번호로_발송한다(): void
{
Bus::fake();
@@ -304,40 +355,43 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
$builder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
->send($guest, $this->notification($data));
$this->makeDriver($this->templateRow(), $builder)->send($guest, $this->notification($data));
Bus::assertDispatched(SendMessageJob::class);
$this->assertSame('01099990000', $builder->calls[0]['to']);
}
public function test_카카오_본문의_변수를_알림_data로_치환해_발송한다(): void
/**
* @effects snapshot_content_substituted_with_notification_data
*/
public function test_스냅샷_본문의_변수를_알림_data로_치환해_발송한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$builder = $this->spyBuilder();
// 알림톡 본문은 카카오 승인 템플릿(#{var})에서 오고, 발송 시 알림 data 로 치환된다.
$this->makeDriver(
$this->fakeTemplate(),
$this->binding(),
$this->templateRow(approved: ['templateContent' => '#{name}님 #{order_number} 주문 완료']),
$builder,
kakao: ['templateCode' => 'TW_1234', 'templateContent' => '#{name}님 #{order_number} 주문 완료'],
)->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
$this->assertSame(
'김철수님 A1 주문 완료',
$builder->calls[0]['message'],
'카카오 본문의 #{var} 를 알림 data 로 치환해야 한다.',
'승인 스냅샷의 #{var} 를 알림 data 로 치환해야 한다.',
);
}
public function test_카카오_버튼을_발송_extra로_전달한다(): void
/**
* @effects snapshot_buttons_mapped_to_dispatch_button_fields
*/
public function test_스냅샷_버튼을_발송_extra로_전달한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
// 버튼 URL 변수까지 치환돼 payload button 에 실려야 한다(회귀: 이전엔 버튼 자체가 누락).
// 등록 페이로드(buttons[].linkMo)와 발송 규격(button[].url_mobile)의 필드 대응이
// 매퍼에서 유지돼야 한다 — 스냅샷은 kapi add 페이로드 형태 그대로다.
$builder = new class extends MessagePayloadBuilder
{
/** @var array<int, array<string, mixed>> */
@@ -354,16 +408,13 @@ class AlimtalkChannelDriverTest extends PluginTestCase
};
$this->makeDriver(
$this->fakeTemplate(),
$this->binding(),
$builder,
kakao: [
'templateCode' => 'TW_1234',
$this->templateRow(approved: [
'templateContent' => '#{name}님 주문 완료',
'buttons' => [
['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/orders/#{order_number}'],
],
],
]),
$builder,
)->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
$button = $builder->extras[0]['button'][0];
@@ -371,13 +422,16 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.');
}
public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void
/**
* @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=member
*
* @effects fallback_on_merges_resend_recontent_from_sms_body
*/
public function test_대체발송_on이면_행의_sms_body가_resend로_병합된다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
// 실제 payload 병합을 관찰하려면 실 빌더가 필요하므로, buildAlimtalk 만 최소 stub 하고
// withSmsFallback 결과를 dispatch 된 Job payload 로 검증한다.
$builder = new class extends MessagePayloadBuilder
{
public function __construct() {}
@@ -388,29 +442,103 @@ class AlimtalkChannelDriverTest extends PluginTestCase
}
};
// replaceVariables 는 치환 완료본을 반환하므로 mock body 도 치환 완료 텍스트로 준다.
// 드라이버는 이 body 를 알림톡 본문(#{var} 변환)과 대체 SMS 본문(원문 그대로) 두 곳에 쓴다.
// 대체 SMS 본문 소스는 코어 템플릿이 아니라 행의 sms_body(#{var} 치환)다 (#597 §3.5).
$this->makeDriver(
$this->fakeTemplate(['subject' => '', 'body' => '김철수 님 주문 완료']),
$this->binding(fallback: true),
$this->templateRow(fallback: true, smsBody: '[샵] #{name} 님 주문 완료'),
$builder,
)->send($member, $this->notification());
)->send($member, $this->notification(['name' => '김철수']));
Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
$this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null, '대체발송 ON 은 resend:{first:sms} 를 넣어야 한다.');
$this->assertArrayHasKey('recontent', $job->payload);
$this->assertSame('김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 치환 완료된 코어 본문(#{var} 미변환)이어야 한다.');
$this->assertSame('[샵] 김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 행의 sms_body 를 치환한 값이어야 한다.');
return true;
});
}
public function test_대체발송_of_f이면_resend가_없다(): void
/**
* @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=guest
*
* @effects fallback_on_merges_resend_recontent_from_sms_body, guest_phone_resolved_from_data_recipient_phone
*/
public function test_비회원도_대체발송_on이면_같은_전화번호로_resend가_병합된다(): void
{
Bus::fake();
$guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
$data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
// 대체 SMS 는 비즈뿌리오가 같은 수신번호로 재발송한다(resend 위임) — 비회원도 동일 경로다.
$this->makeDriver($this->templateRow(fallback: true, smsBody: '[샵] #{name} 님 주문 완료'))
->send($guest, $this->notification($data));
Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
$this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null);
$this->assertSame('[샵] 홍길동 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null);
$this->assertSame('01099990000', $job->payload['to'] ?? null, '대체 SMS 도 알림톡과 같은 수신번호를 쓴다.');
return true;
});
}
/**
* 대체 SMS 도 수신자 로케일로 렌더된다 (#597 §14.3).
*
* 같은 알림의 SMS 단독 발송(SmsChannelDriver)과 대체발송이 서로 다른 언어로 나가면
* 안 된다 — 두 경로가 같은 sms_body 맵을 같은 규칙으로 읽는지 고정한다.
*
* @scenario template_state=approved, fallback_sms=on, sms_only=off, recipient=guest
*
* @effects fallback_on_merges_resend_recontent_from_sms_body, sms_body_rendered_in_recipient_locale
*/
public function test_대체발송_본문도_수신자_로케일로_렌더된다(): void
{
Bus::fake();
$guest = new GuestNotifiable('guest@example.com', 'John', 'en');
$data = ['name' => 'John', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
$this->makeDriver($this->templateRow(fallback: true, smsBody: [
'ko' => '[샵] #{name} 님 주문 완료',
'en' => '[Shop] #{name}, your order is complete.',
]))->send($guest, $this->notification($data));
Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
$this->assertSame(
'[Shop] John, your order is complete.',
$job->payload['recontent']['sms']['message'] ?? null,
'대체 SMS 본문은 수신자 로케일(en)로 렌더되어야 한다.'
);
return true;
});
}
/**
* @effects fallback_on_with_empty_sms_body_skips_merge
*/
public function test_대체발송_on이라도_sms_body가_비어있으면_resend를_병합하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$this->makeDriver($this->fakeTemplate(), $this->binding(fallback: false))
$this->makeDriver($this->templateRow(fallback: true, smsBody: ' '))
->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
$this->assertArrayNotHasKey('resend', $job->payload, '빈 대체 본문으로 빈 SMS 를 보내면 안 된다.');
return true;
});
}
/**
* @effects fallback_off_has_no_resend
*/
public function test_대체발송_off이면_resend가_없다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$this->makeDriver($this->templateRow(fallback: false, smsBody: '대체 본문'))
->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
@@ -420,12 +548,15 @@ class AlimtalkChannelDriverTest extends PluginTestCase
});
}
/**
* @effects pending_dispatch_row_created_with_channel_and_user
*/
public function test_회원_발송_시_pending_이력을_alimtalk_채널로_생성한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']);
$this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification());
$this->makeDriver($this->templateRow())->send($member, $this->notification());
$this->assertDatabaseCount('bizppurio_dispatches', 1);
$dispatch = BizppurioDispatch::first();
@@ -441,36 +572,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$builder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
->send($member, $this->notification());
$this->makeDriver($this->templateRow(), $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 는 다른 컬럼에 없으므로 남아있어야 한다.');
$this->assertNotNull($dispatch->request_payload, '실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.');
$this->assertSame('g7_abcd1234_1', $dispatch->request_payload['templatecode'] ?? null);
}
public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void
@@ -481,8 +587,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->fakeTemplate(), $this->binding())
->send($guest, $this->notification(['name' => '홍길동']));
$this->makeDriver($this->templateRow())->send($guest, $this->notification(['name' => '홍길동']));
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
@@ -493,31 +598,17 @@ class AlimtalkChannelDriverTest extends PluginTestCase
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, new Notification);
$this->makeDriver($this->templateRow())->send($member, new Notification);
Bus::assertNotDispatched(SendMessageJob::class);
}
public function test_정상_조건에서_발송하고_이력을_생성한다(): void
{
// 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
// 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification());
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->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification());
$this->assertTrue(BizppurioDispatch::first()->is_test_mode);
}
@@ -527,8 +618,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: false)
->send($member, $this->notification());
$this->makeDriver($this->templateRow(), isTestMode: false)->send($member, $this->notification());
$this->assertFalse(BizppurioDispatch::first()->is_test_mode);
}
@@ -19,6 +19,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase
return new AlimtalkPayloadMapper;
}
/**
* @effects snapshot_content_substituted_with_notification_data
*/
public function test_본문의_변수를_치환해_message로_반환한다(): void
{
$result = $this->mapper()->map(
@@ -29,6 +32,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase
$this->assertSame('홍길동님 주문 A123 완료', $result['message']);
}
/**
* @effects snapshot_buttons_mapped_to_dispatch_button_fields
*/
public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void
{
$result = $this->mapper()->map(
@@ -56,6 +62,9 @@ class AlimtalkPayloadMapperTest extends PluginTestCase
$this->assertArrayNotHasKey('linkMo', $button);
}
/**
* @effects snapshot_buttons_mapped_to_dispatch_button_fields
*/
public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void
{
$result = $this->mapper()->map(
@@ -272,4 +281,67 @@ class AlimtalkPayloadMapperTest extends PluginTestCase
$this->assertArrayNotHasKey('button', $result['extra']);
}
public function test_substitute_text는_임의_텍스트의_변수를_치환한다(): void
{
// 대체 SMS·SMS 단독 본문(sms_body)이 이 공개 진입점으로 치환된다 (#597 §3.5).
$result = (new AlimtalkPayloadMapper)->substituteText(
'[샵] #{name}님 #{order_number} 주문',
['name' => '김철수', 'order_number' => 'A1'],
);
$this->assertSame('[샵] 김철수님 A1 주문', $result);
}
/**
* @effects snapshot_content_substituted_with_notification_data, snapshot_buttons_mapped_to_dispatch_button_fields
*/
public function test_등록_페이로드_형태의_승인_스냅샷을_그대로_소비한다(): void
{
// #597: 발송 소스가 kapi 상세 응답에서 등록 페이로드 스냅샷(approved_content)으로
// 전환됐다. 두 형태는 필드명이 동일하다는 대응표를 여기서 고정한다:
// templateContent→message / buttons[].linkMo→button[].url_mobile /
// quickReplies→quickreply / templateTitle→title / templateHeader→header /
// templateItem→item / templateItemHighlight→itemhighlight / templateRepresentLink→link
$snapshot = [
'templateName' => '주문 완료',
'templateMessageType' => 'MI',
'templateEmphasizeType' => 'ITEM_LIST',
'templateContent' => '#{name}님 주문 완료',
'templateTitle' => '주문 #{order_number}',
'templateHeader' => '헤더',
'categoryCode' => '001001',
'buttons' => [
['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order_number}'],
],
'quickReplies' => [
['name' => '문의', 'linkType' => 'BK'],
],
'templateItem' => [
'list' => [
['title' => '품목', 'description' => '#{item_name}'],
['title' => '수량', 'description' => '#{qty}'],
],
'summary' => ['title' => '합계', 'description' => '#{total}원'],
],
'templateItemHighlight' => ['title' => '#{name}님 주문', 'description' => '결제 완료'],
'templateRepresentLink' => ['linkMo' => 'https://m.shop/orders'],
];
$result = (new AlimtalkPayloadMapper)->map($snapshot, [
'name' => '김철수', 'order_number' => 'A1', 'item_name' => '연필', 'qty' => '2', 'total' => '1000',
]);
$this->assertSame('김철수님 주문 완료', $result['message']);
$this->assertSame('https://m.shop/A1', $result['extra']['button'][0]['url_mobile']);
$this->assertSame('BK', $result['extra']['quickreply'][0]['type']);
$this->assertSame('주문 A1', $result['extra']['title']);
$this->assertSame('헤더', $result['extra']['header']);
$this->assertSame('연필', $result['extra']['item']['list'][0]['description']);
$this->assertSame('1000원', $result['extra']['item']['summary']['description']);
$this->assertSame('김철수님 주문', $result['extra']['itemhighlight']['title']);
$this->assertSame('https://m.shop/orders', $result['extra']['link']['url_mobile']);
// 등록 전용 메타(templateName 등)는 발송 extra 에 실리지 않는다.
$this->assertArrayNotHasKey('templateName', $result['extra']);
}
}
@@ -11,96 +11,76 @@ use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* AlimtalkTemplateService — kapi 조회 위임 + 상태 배지 매핑 검증 (조회 전용).
* AlimtalkTemplateService — 작성 모달 참조 조회(카테고리·발신프로필) 위임 검증 (#597).
*
* 템플릿 목록/상세의 실시간 조회(구 Phase 5)는 DB 기반 라이프사이클
* (BizppurioTemplateService)로 대체되어 제거됐다 — 이 서비스는 카테고리와
* 발신프로필 조회만 남는다.
*/
class AlimtalkTemplateServiceTest extends PluginTestCase
{
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* @param array<string, string> $settings
* kapi 자격증명이 준비된 서비스 인스턴스를 생성한다.
*/
private function service(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): AlimtalkTemplateService
private function service(): AlimtalkTemplateService
{
$pluginSettings = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
$pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)
->andReturn(['bizppurio_id' => 'biz01', 'api_key' => 'key01']);
$kakao = new BizppurioKakaoApiClient($pluginSettings);
return new AlimtalkTemplateService($kakao, $pluginSettings);
return new AlimtalkTemplateService(new BizppurioKakaoApiClient($pluginSettings));
}
public function test_목록은_상태_배지를_부가한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'totalCount' => 1,
'totalPage' => 1,
'currentPage' => 1,
'data' => ['list' => [
['templateCode' => 'TW_1', 'templateName' => '주문완료', 'serviceStatus' => 'ACT'],
]],
], 200),
]);
$result = $this->service()->list(['status' => 'ACT']);
$this->assertCount(1, $result['templates']);
$tpl = $result['templates'][0];
$this->assertSame('ACT', $tpl['service_status']);
$this->assertSame('green', $tpl['status_badge']['variant']);
// 회귀 방지: 배지 label_key 는 프론트 lang 키 형식(templates.status.*)이어야 한다.
// 백엔드 messages.php 네임스페이스(::messages.template.status.*)로 주면 프론트 $t() 가
// 해석하지 못해 라벨 원문이 목록/상세에 그대로 노출된다(PO 브라우저 검수로 발견된 회귀).
$this->assertSame(
'sirsoft-message_bizppurio.templates.status.sendable',
$tpl['status_badge']['label_key'],
);
// 조회 전용 — 상태별 가능 액션(available_actions)은 더 이상 부가하지 않는다.
$this->assertArrayNotHasKey('available_actions', $tpl);
$this->assertSame(1, $result['pagination']['total']);
}
public function test_목록은_status_keyword를_kapi에_전달한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['list' => []]], 200)]);
$this->service()->list(['status' => 'REQ', 'keyword' => '주문', 'page' => 2, 'count' => 10]);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/list')
&& $request['templateStatus'] === 'REQ'
&& $request['keyword'] === '주문'
&& $request['page'] === 2
&& $request['count'] === 10
&& $request['senderKey'] === 'SK_40';
});
}
public function test_상세는_status_inspection으로_배지를_추론한다(): void
/**
* 카테고리 전체 조회는 data 배열을 그대로 반환한다.
*/
public function test_카테고리는_data_배열을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => [
'templateCode' => 'TW_2',
'inspectionStatus' => 'APR',
'status' => 'A',
'block' => false,
'dormant' => false,
['code' => '001001', 'name' => '회원가입', 'groupName' => '회원'],
['code' => '002001', 'name' => '구매완료', 'groupName' => '구매'],
],
], 200),
]);
$detail = $this->service()->detail('TW_2');
$categories = $this->service()->categories();
// inspection=APR + status=A → ACT(정상)
$this->assertSame('ACT', $detail['service_status']);
$this->assertSame('green', $detail['status_badge']['variant']);
$this->assertCount(2, $categories);
$this->assertSame('001001', $categories[0]['code']);
$this->assertSame('구매', $categories[1]['groupName']);
Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/category/all')
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01');
}
public function test_발신프로필은_data_success_배열을_반환한다(): void
/**
* 카테고리 조회가 실패 코드로 응답하면 message 원문·resultCode 를 보존한 예외를 던진다.
*/
public function test_카테고리_실패코드시_message와_결과코드를_예외에_보존한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '405', 'message' => '지원하지 않는 기능입니다.'], 200),
]);
try {
$this->service()->categories();
$this->fail('BizppurioApiException 이 발생해야 한다.');
} catch (BizppurioApiException $e) {
$this->assertSame('지원하지 않는 기능입니다.', $e->getMessage());
$this->assertSame('405', $e->getResultCode());
}
}
/**
* 발신프로필은 data.success 배열만 반환한다 (success/fail 껍데기 미노출).
*/
public function test_발신프로필은_data_success_배열만_반환한다(): void
{
// 규격(5.발신프로필관리): /v3/kakao/profile/use 응답 data 는 {success:[...], fail:[...]}
// 2단 봉투다. 실제 발신프로필 목록은 data.success 안에 있으므로 그 배열을 반환해야 한다.
@@ -122,49 +102,28 @@ class AlimtalkTemplateServiceTest extends PluginTestCase
$this->assertCount(1, $profiles);
$this->assertSame('SK_40', $profiles[0]['senderKey']);
$this->assertSame('테스트채널', $profiles[0]['name']);
// 회귀 방지: data 통째 반환 시 노출되던 success/fail 키가 없어야 한다.
$this->assertArrayNotHasKey('success', $profiles);
$this->assertArrayNotHasKey('fail', $profiles);
}
public function test_발신프로필_키_미설정시_예외(): void
{
$this->expectException(BizppurioApiException::class);
$this->service(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => ''])
->list();
}
public function test_kapi_실패코드시_예외에_결과코드가_담긴다(): void
/**
* 발신프로필 조회가 실패 코드로 응답하면 예외를 던진다.
*/
public function test_발신프로필_실패코드시_예외(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '템플릿 불일치'], 200),
'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '발신프로필을 찾을 수 없습니다.'], 200),
]);
try {
$this->service()->list();
$this->fail('예외가 발생해야 한다.');
$this->service()->senderProfiles();
$this->fail('BizppurioApiException 이 발생해야 한다.');
} catch (BizppurioApiException $e) {
$this->assertSame('발신프로필을 찾을 수 없습니다.', $e->getMessage());
$this->assertSame('7204', $e->getResultCode());
$this->assertStringContainsString('템플릿 불일치', $e->getMessage());
}
}
public function test_목록조회는_kapi_508을_빈_목록으로_처리한다(): void
{
// 카카오 결과코드 508 = "요청한 데이터가 없음"(13.응답코드정의.md). 목록 검색에서
// 매칭 결과가 0건일 때 카카오가 이 코드로 응답하므로, 진짜 에러가 아니라 빈 목록으로
// 취급해야 한다(PO 실측: "댓글"은 200 정상 필터링, "대글"처럼 매칭 없는 키워드만 508).
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '508', 'message' => '요청한 데이타가 없습니다.'], 200),
]);
$result = $this->service()->list(['keyword' => '대글']);
$this->assertSame([], $result['templates']);
$this->assertSame(0, $result['pagination']['total']);
}
protected function tearDown(): void
{
Mockery::close();
@@ -143,6 +143,193 @@ class BizppurioKakaoApiClientTest extends PluginTestCase
}
}
public function test_템플릿_등록은_add_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->addTemplate([
'senderKey' => 'SK',
'templateCode' => 'TW_1',
'templateName' => '주문완료',
]);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/add')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['templateName'] === '주문완료'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_템플릿_수정은_update_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->updateTemplate([
'senderKey' => 'SK',
'templateCode' => 'TW_1',
'newTemplateCode' => 'TW_2',
]);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/update')
&& $request['newTemplateCode'] === 'TW_2'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_템플릿_삭제는_delete_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->deleteTemplate('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/delete')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_템플릿_코드_중복검증은_code_check_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->checkTemplateCode('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/codeCheck')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_검수요청은_comment를_포함해_request_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->requestInspection('SK', 'TW_1', '변수는 주문번호입니다.');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/request')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['comment'] === '변수는 주문번호입니다.'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_검수요청_comment_미전달시_comment_키를_싣지_않는다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->requestInspection('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/request')
&& $request['senderKey'] === 'SK'
&& ! array_key_exists('comment', $request->data())
&& $request['bizId'] === 'biz01';
});
}
public function test_검수취소는_cancel_request_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->cancelInspection('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/cancel_request')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_승인취소는_cancel_approval_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->cancelApproval('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/cancel_approval')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_휴면해제는_release_경로로_pos_t한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->client()->releaseDormant('SK', 'TW_1');
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/release')
&& $request['senderKey'] === 'SK'
&& $request['templateCode'] === 'TW_1'
&& $request['bizId'] === 'biz01'
&& $request['apiKey'] === 'key01';
});
}
public function test_이미지_업로드는_multipart로_전송하고_최상위_image_필드를_반환한다(): void
{
// kapi 유일의 비-JSON(multipart) 엔드포인트 — 응답도 data 봉투가 아니라
// 최상위 image 필드에 업로드 URL 이 실린다(부록 A-7).
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'message' => 'success',
'image' => 'https://mud-kage.kakao.com/dn/example.png',
], 200),
]);
$tmpPath = tempnam(sys_get_temp_dir(), 'biz_img_');
file_put_contents($tmpPath, 'fake-png-bytes');
try {
$result = $this->client()->uploadTemplateImage($tmpPath, 'template.png');
} finally {
@unlink($tmpPath);
}
$this->assertSame('https://mud-kage.kakao.com/dn/example.png', $result['image']);
$this->assertArrayNotHasKey('data', $result);
Http::assertSent(function ($request) {
if (! str_contains($request->url(), '/v3/kakao/image/alimtalk/template')) {
return false;
}
if (! $request->isMultipart()) {
return false;
}
// multipart 파트에 image 파일 + bizId/apiKey 필드가 실려야 한다.
// 파일 contents 는 스트림 리소스로 실리므로(전송 후 close) 값 비교가 불가 —
// 파트명 + 원본 파일명으로 단언한다.
$parts = collect($request->data());
return $request->hasFile('image', null, 'template.png')
&& $parts->contains(fn ($part) => ($part['name'] ?? null) === 'bizId' && ($part['contents'] ?? null) === 'biz01')
&& $parts->contains(fn ($part) => ($part['name'] ?? null) === 'apiKey' && ($part['contents'] ?? null) === 'key01');
});
}
protected function tearDown(): void
{
Mockery::close();
@@ -0,0 +1,736 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioTemplateRepository;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* BizppurioTemplateService — 라이프사이클 오케스트레이션 검증 (#597 §3.2·§3.4).
*
* 채번(codeCheck 재시도)·검수 신청(add/update 분기)·전이 가드·동기화(승인 스냅샷 동결·
* 반려 사유 저장)·삭제(카카오측 조건부 동반 삭제)를 Http::fake 요청 캡처로 검증한다.
* kapi 는 오류도 HTTP 200 + body code 로 반환한다(부록 A-9) — fake 응답도 그 규약을 따른다.
*/
class BizppurioTemplateServiceTest extends PluginTestCase
{
/** kapi 도메인 매처 */
private const KAPI = 'kapi.ppurio.com/*';
/**
* Http::fake 응답 봉투를 만듭니다 (kapi 는 실패도 HTTP 200 + body code).
*
* @param string $code kapi 결과코드
* @param array<string, mixed> $extra data 등 추가 필드
*/
private function kapiResponse(string $code = '200', array $extra = []): array
{
return array_merge(['code' => $code, 'message' => $code === '200' ? 'success' : 'kapi failure '.$code], $extra);
}
/**
* 서비스를 조립합니다 (실 저장소 + 실 클라이언트 + mock 설정).
*/
private function makeService(string $senderKey = 'SK_TEST'): BizppurioTemplateService
{
$settings = Mockery::mock(PluginSettingsService::class);
$settings->shouldReceive('get')
->with('sirsoft-message_bizppurio')
->andReturn(['bizppurio_id' => 'biz1', 'api_key' => 'key1']);
$settings->shouldReceive('get')
->with('sirsoft-message_bizppurio', 'sender_key', '')
->andReturn($senderKey);
return new BizppurioTemplateService(
new BizppurioTemplateRepository,
new BizppurioKakaoApiClient($settings),
$settings,
);
}
/**
* 템플릿 행을 DB 에 만듭니다.
*
* @param array<string, mixed> $attrs 오버라이드
*/
private function row(array $attrs = []): BizppurioTemplate
{
return BizppurioTemplate::create(array_merge([
'notification_type' => 'welcome',
'status' => BizppurioTemplateStatus::Draft->value,
'content' => ['templateName' => '환영', 'templateMessageType' => 'BA', 'templateEmphasizeType' => 'NONE', 'templateContent' => '#{name}님 환영합니다', 'categoryCode' => '001001'],
], $attrs));
}
public function test_create는_draft_상태로_생성한다(): void
{
$template = $this->makeService()->create([
'notification_type' => 'welcome',
'content' => ['templateName' => '환영', 'templateContent' => '본문'],
]);
$this->assertSame(BizppurioTemplateStatus::Draft, $template->status);
$this->assertDatabaseHas('bizppurio_templates', ['notification_type' => 'welcome']);
}
public function test_draft에서_content를_수정할_수_있다(): void
{
$template = $this->row();
$updated = $this->makeService()->update($template, [
'content' => ['templateName' => '환영2', 'templateContent' => '새 본문'],
]);
$this->assertSame('환영2', $updated->content['templateName']);
}
/**
* @effects content_edit_locked_outside_draft_rejected
*/
public function test_requested_상태에서_content_변경은_거부된다(): void
{
$template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]);
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->update($template, [
'content' => ['templateName' => '변경', 'templateContent' => '변경 본문'],
]);
}
/**
* @effects sms_delivery_fields_editable_in_any_status
*/
public function test_requested_상태라도_sms_설정은_수정할_수_있다(): void
{
$template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]);
$updated = $this->makeService()->update($template, [
'fallback_sms_enabled' => true,
'sms_body' => ['ko' => '[샵] #{name} 님 환영'],
]);
$this->assertTrue($updated->fallback_sms_enabled);
$this->assertSame(['ko' => '[샵] #{name} 님 환영'], $updated->sms_body);
}
/**
* @effects content_edit_locked_outside_draft_rejected
*/
public function test_requested_상태에서는_동일_content_전달도_거부된다(): void
{
$template = $this->row(['status' => BizppurioTemplateStatus::Requested->value]);
// content 키가 실리면 값의 동일 여부와 무관하게 잠긴다 (#597 §3.2).
// "값이 달라질 때만 막는다" 완화는 라운드 3 에서 철회했다 — DB 가 JSON 키 순서를
// 정규화해 저장하므로 동일성 비교가 성립하지 않았고(항상 '변경됨'), 화면도 검수중·
// 승인 상태에서는 수정 진입 자체를 막고 있어 완화가 보호하는 경로가 없었다.
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->update($template, [
'content' => $template->content,
'sms_only' => true,
]);
}
/**
* @effects sms_delivery_fields_editable_in_any_status
*/
public function test_approved_상태에서도_content_키가_없으면_수정된다(): void
{
$template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]);
// 발송 설정만 보내는 경로(행 토글·SMS 모달)는 content 키를 싣지 않으므로 잠금 대상이 아니다.
$updated = $this->makeService()->update($template, ['sms_only' => true]);
$this->assertTrue($updated->sms_only);
}
/**
* @effects content_edit_locked_outside_draft_rejected
*/
public function test_approved_상태에서_content_변경은_거부된다(): void
{
$template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]);
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->update($template, [
'content' => ['templateName' => '변경', 'templateContent' => '변경 본문'],
]);
}
public function test_upsert_delivery는_행이_없으면_draft로_생성한다(): void
{
$template = $this->makeService()->upsertDelivery('welcome', ['sms_only' => true, 'sms_body' => ['ko' => '본문']]);
$this->assertSame(BizppurioTemplateStatus::Draft, $template->status);
$this->assertTrue($template->sms_only);
$this->assertNull($template->content);
}
public function test_upsert_delivery는_기존_행의_발송_설정만_갱신한다(): void
{
$existing = $this->row(['status' => BizppurioTemplateStatus::Approved->value]);
$template = $this->makeService()->upsertDelivery('welcome', ['fallback_sms_enabled' => true]);
$this->assertSame($existing->id, $template->id);
$this->assertTrue($template->fallback_sms_enabled);
$this->assertSame(BizppurioTemplateStatus::Approved, $template->status, '발송 설정 upsert 는 검수 상태를 건드리지 않는다.');
}
/**
* @scenario content_variant=ba_none, transition=draft_to_requested
*
* @effects request_generates_code_with_codecheck_retry_up_to_three_generations, first_request_calls_add_then_request_with_full_content_payload, request_snapshots_sender_key
*/
public function test_최초_검수_신청은_채번_add_request_순으로_호출하고_requested가_된다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row();
$updated = $this->makeService()->requestInspection($template);
$this->assertSame(BizppurioTemplateStatus::Requested, $updated->status);
$this->assertSame('SK_TEST', $updated->sender_key, '신청 당시 발신프로필을 스냅샷한다.');
$this->assertNotNull($updated->requested_at);
$this->assertMatchesRegularExpression('/^g7_[0-9a-f]{8}_1$/', (string) $updated->template_code, '자체 채번 형식(g7_{md5 8자}_{세대}).');
// 호출 순서: codeCheck → add → request (총 3회)
$paths = [];
Http::assertSentCount(3);
Http::assertSent(function ($request) use (&$paths) {
$paths[] = parse_url($request->url(), PHP_URL_PATH);
return true;
});
$this->assertSame([
'/v3/kakao/template/codeCheck',
'/v3/kakao/template/add',
'/v3/kakao/template/request',
], $paths);
}
public function test_검수_신청의_add_요청_본문은_content와_채번_코드를_싣는다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row();
$updated = $this->makeService()->requestInspection($template);
Http::assertSent(function ($request) use ($updated) {
if (parse_url($request->url(), PHP_URL_PATH) !== '/v3/kakao/template/add') {
return false;
}
$body = $request->data();
$this->assertSame('biz1', $body['bizId'] ?? null, '클라이언트가 bizId 를 주입해야 한다.');
$this->assertSame('key1', $body['apiKey'] ?? null);
$this->assertSame('SK_TEST', $body['senderKey'] ?? null);
$this->assertSame($updated->template_code, $body['templateCode'] ?? null);
$this->assertSame('환영', $body['templateName'] ?? null, 'content 필드가 등록 페이로드로 그대로 실려야 한다.');
$this->assertSame('#{name}님 환영합니다', $body['templateContent'] ?? null);
return true;
});
}
public function test_code_check_충돌시_세대를_올려_재시도한다(): void
{
Http::fake([
'*codeCheck*' => Http::sequence()
->push($this->kapiResponse('504'))
->push($this->kapiResponse('200')),
self::KAPI => Http::response($this->kapiResponse()),
]);
$template = $this->row();
$updated = $this->makeService()->requestInspection($template);
$this->assertStringEndsWith('_2', (string) $updated->template_code, '충돌 시 세대 2 로 재시도해야 한다.');
}
public function test_code_check가_전부_충돌하면_예외를_던지고_코드를_확정하지_않는다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse('504'))]);
$template = $this->row();
try {
$this->makeService()->requestInspection($template);
$this->fail('BizppurioApiException 이 발생해야 한다.');
} catch (BizppurioApiException) {
$this->assertNull($template->fresh()->template_code, '재시도 소진 시 코드가 확정되면 안 된다.');
$this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status);
}
}
/**
* @effects rerequest_calls_update_not_add
*/
public function test_재신청은_add가_아니라_update를_호출한다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row([
'status' => BizppurioTemplateStatus::Rejected->value,
'template_code' => 'g7_deadbeef_1',
'sender_key' => 'SK_SNAP',
]);
$this->makeService()->requestInspection($template);
// 재신청: update → request (codeCheck·add 없음)
$paths = [];
Http::assertSent(function ($request) use (&$paths) {
$paths[] = parse_url($request->url(), PHP_URL_PATH);
return true;
});
$this->assertSame(['/v3/kakao/template/update', '/v3/kakao/template/request'], $paths);
}
public function test_content_없이_검수_신청하면_거부된다(): void
{
Http::fake();
$template = $this->row(['content' => null]);
$this->expectException(BizppurioTemplateStateException::class);
try {
$this->makeService()->requestInspection($template);
} finally {
Http::assertNothingSent();
}
}
public function test_approved_상태에서_검수_신청은_거부된다(): void
{
Http::fake();
$template = $this->row(['status' => BizppurioTemplateStatus::Approved->value]);
$this->expectException(BizppurioTemplateStateException::class);
try {
$this->makeService()->requestInspection($template);
} finally {
Http::assertNothingSent();
}
}
/**
* @effects concurrent_duplicate_request_claims_single_submission
*/
public function test_동시_중복_신청은_한_건만_kapi에_제출된다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row();
// 경합 재현: 같은 draft 상태를 보는 두 번째 모델 핸들 — 더블 클릭·이중 탭에서
// 각 요청의 route model binding 이 서로의 커밋을 모른 채 같은 행을 든 상황.
$stale = BizppurioTemplate::query()->findOrFail($template->id);
$this->makeService()->requestInspection($template);
try {
$this->makeService()->requestInspection($stale);
$this->fail('BizppurioTemplateStateException 이 발생해야 한다.');
} catch (BizppurioTemplateStateException) {
// 첫 신청의 codeCheck·add·request 3회뿐 — 두 번째 신청은 kapi 에 도달하지 않는다.
Http::assertSentCount(3);
$this->assertSame(BizppurioTemplateStatus::Requested, $template->fresh()->status);
}
}
public function test_kapi_add_실패시_사유_원문을_보존하고_상태를_바꾸지_않는다(): void
{
Http::fake([
'*codeCheck*' => Http::response($this->kapiResponse()),
'*template/add*' => Http::response(['code' => '507', 'message' => '유효하지 않은 발신프로필']),
]);
$template = $this->row();
try {
$this->makeService()->requestInspection($template);
$this->fail('BizppurioApiException 이 발생해야 한다.');
} catch (BizppurioApiException $e) {
$this->assertSame('유효하지 않은 발신프로필', $e->getMessage(), 'kapi 사유 원문이 보존돼야 한다.');
$this->assertSame('507', $e->getResultCode());
$this->assertSame(BizppurioTemplateStatus::Draft, $template->fresh()->status);
}
}
/**
* @scenario content_variant=ba_none, transition=requested_cancel
*
* @effects cancel_request_returns_to_draft
*/
public function test_검수_신청_취소는_draft로_복귀한다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row([
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_deadbeef_1',
'sender_key' => 'SK_SNAP',
]);
$updated = $this->makeService()->cancelRequest($template);
$this->assertSame(BizppurioTemplateStatus::Draft, $updated->status);
Http::assertSent(fn ($request) => parse_url($request->url(), PHP_URL_PATH) === '/v3/kakao/template/cancel_request'
&& ($request->data()['senderKey'] ?? null) === 'SK_SNAP');
}
public function test_검수중이_아니면_신청_취소가_거부된다(): void
{
Http::fake();
$template = $this->row();
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->cancelRequest($template);
}
/**
* @scenario content_variant=ba_none, transition=approved_cancel
*
* @effects cancel_approval_returns_to_draft_keeping_snapshot, cancel_approval_immediately_blocks_dispatch_gate
*/
public function test_승인_취소는_draft로_복귀하되_승인_스냅샷을_유지한다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$approved = ['templateContent' => '승인된 본문'];
$template = $this->row([
'status' => BizppurioTemplateStatus::Approved->value,
'template_code' => 'g7_deadbeef_1',
'approved_content' => $approved,
'approved_at' => now(),
]);
$updated = $this->makeService()->cancelApproval($template);
$this->assertSame(BizppurioTemplateStatus::Draft, $updated->status);
$this->assertSame($approved, $updated->approved_content, '승인 스냅샷은 유지된다 — 발송 차단은 status 게이트가 담당.');
$this->assertFalse($updated->isAlimtalkSendable(), '스냅샷이 남아도 status 를 잃는 순간 발송 게이트가 차단돼야 한다.');
}
public function test_승인_상태가_아니면_승인_취소가_거부된다(): void
{
Http::fake();
$template = $this->row();
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->cancelApproval($template);
}
/**
* @scenario content_variant=ba_none, transition=requested_to_approved
*
* @effects approval_transition_freezes_content_into_approved_content, manual_sync_updates_single_row
*/
public function test_sync는_승인_전이시_content를_승인_스냅샷으로_동결한다(): void
{
Http::fake([
'*template/detail*' => Http::response($this->kapiResponse('200', [
'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'RDY'],
])),
]);
$template = $this->row([
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_deadbeef_1',
'sender_key' => 'SK_SNAP',
]);
$updated = $this->makeService()->sync($template);
$this->assertSame(BizppurioTemplateStatus::Approved, $updated->status);
$this->assertSame($template->content, $updated->approved_content, '승인 전이 시 content 가 발송 SSoT 로 동결돼야 한다.');
$this->assertNotNull($updated->approved_at);
$this->assertNotNull($updated->last_synced_at);
}
public function test_sync는_이미_승인이면_스냅샷을_다시_덮지_않는다(): void
{
Http::fake([
'*template/detail*' => Http::response($this->kapiResponse('200', [
'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'ACT'],
])),
]);
$frozen = ['templateContent' => '승인 당시 본문'];
$template = $this->row([
'status' => BizppurioTemplateStatus::Approved->value,
'template_code' => 'g7_deadbeef_1',
'approved_content' => $frozen,
'content' => ['templateContent' => '이후 수정한 본문(미승인)'],
]);
$updated = $this->makeService()->sync($template);
$this->assertSame($frozen, $updated->approved_content, '승인 유지 상태에서 스냅샷이 현재 content 로 덮이면 안 된다.');
}
/**
* @scenario content_variant=ba_none, transition=requested_to_rejected
*
* @effects rejection_transition_stores_comments_detail
*/
public function test_sync는_반려_전이시_comments를_저장한다(): void
{
$comments = [['status' => 'REJ', 'content' => '심사 기준 미달', 'createdAt' => '2026-08-19 10:00:00']];
Http::fake([
'*template/detail*' => Http::response($this->kapiResponse('200', [
'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'REJ', 'comments' => $comments],
])),
]);
$template = $this->row([
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_deadbeef_1',
]);
$updated = $this->makeService()->sync($template);
$this->assertSame(BizppurioTemplateStatus::Rejected, $updated->status);
$this->assertSame($comments, $updated->inspection_detail, '반려 사유 원문(comments)이 저장돼야 한다.');
}
public function test_sync는_코드가_없는_행을_kapi_호출_없이_그대로_반환한다(): void
{
Http::fake();
$template = $this->row();
$result = $this->makeService()->sync($template);
$this->assertSame($template->id, $result->id);
Http::assertNothingSent();
}
public function test_sync_requested는_대상이_없으면_kapi를_호출하지_않는다(): void
{
Http::fake();
$this->row(); // draft 행만 존재
$result = $this->makeService()->syncRequested();
$this->assertSame(['checked' => 0, 'transitioned' => 0], $result);
Http::assertNothingSent();
}
public function test_sync_requested는_list_일괄_대조로_전이한다(): void
{
Http::fake([
'*template/list*' => Http::response($this->kapiResponse('200', [
'data' => [
'totalPage' => 1,
'list' => [
['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'ACT'],
['templateCode' => 'g7_bbbb2222_1', 'serviceStatus' => 'REQ'],
],
],
])),
]);
$a = $this->row([
'notification_type' => 'welcome',
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_aaaa1111_1',
'sender_key' => 'SK_SNAP',
]);
$b = $this->row([
'notification_type' => 'order_confirmed',
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_bbbb2222_1',
'sender_key' => 'SK_SNAP',
]);
$result = $this->makeService()->syncRequested();
$this->assertSame(2, $result['checked']);
$this->assertSame(1, $result['transitioned']);
$this->assertSame(BizppurioTemplateStatus::Approved, $a->fresh()->status);
$this->assertSame(BizppurioTemplateStatus::Requested, $b->fresh()->status, '여전히 검수중이면 상태 유지.');
// 행별 detail 호출 금지 — list 1회만 (레이트리밋 보호)
Http::assertSentCount(1);
}
public function test_sync_requested는_반려_전이_행만_detail을_추가_호출해_사유를_확보한다(): void
{
$comments = [['status' => 'REJ', 'content' => '변수 규격 위반']];
Http::fake([
'*template/list*' => Http::response($this->kapiResponse('200', [
'data' => ['totalPage' => 1, 'list' => [['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'REJ']]],
])),
'*template/detail*' => Http::response($this->kapiResponse('200', [
'data' => ['templateCode' => 'g7_aaaa1111_1', 'serviceStatus' => 'REJ', 'comments' => $comments],
])),
]);
$row = $this->row([
'status' => BizppurioTemplateStatus::Requested->value,
'template_code' => 'g7_aaaa1111_1',
'sender_key' => 'SK_SNAP',
]);
$this->makeService()->syncRequested();
$fresh = $row->fresh();
$this->assertSame(BizppurioTemplateStatus::Rejected, $fresh->status);
$this->assertSame($comments, $fresh->inspection_detail);
Http::assertSentCount(2); // list 1 + detail 1
}
/**
* @effects delete_removes_kakao_side_only_in_deletable_state
*/
public function test_delete는_삭제_가능_상태면_카카오측도_함께_지운다(): void
{
Http::fake([self::KAPI => Http::response($this->kapiResponse())]);
$template = $this->row([
'template_code' => 'g7_deadbeef_1',
'sender_key' => 'SK_SNAP',
]);
$result = $this->makeService()->delete($template);
$this->assertTrue($result['kakao_deleted']);
$this->assertNull($result['kakao_skip_reason']);
$this->assertDatabaseCount('bizppurio_templates', 0);
Http::assertSent(fn ($request) => parse_url($request->url(), PHP_URL_PATH) === '/v3/kakao/template/delete');
}
public function test_delete는_삭제_불가_상태면_db행만_지우고_사유를_명시한다(): void
{
Http::fake();
$template = $this->row([
'status' => BizppurioTemplateStatus::Approved->value,
'template_code' => 'g7_deadbeef_1',
]);
$result = $this->makeService()->delete($template);
$this->assertFalse($result['kakao_deleted']);
$this->assertSame('state_not_deletable', $result['kakao_skip_reason']);
$this->assertDatabaseCount('bizppurio_templates', 0);
Http::assertNothingSent();
}
public function test_delete는_카카오측_실패에도_db행을_지운다(): void
{
Http::fake([self::KAPI => Http::response(['code' => '509', 'message' => '처리 불가 상태'])]);
$template = $this->row(['template_code' => 'g7_deadbeef_1', 'sender_key' => 'SK_SNAP']);
$result = $this->makeService()->delete($template);
$this->assertFalse($result['kakao_deleted']);
$this->assertSame('처리 불가 상태', $result['kakao_skip_reason']);
$this->assertDatabaseCount('bizppurio_templates', 0);
}
public function test_카카오_미등록_행_delete는_kapi를_호출하지_않는다(): void
{
Http::fake();
$template = $this->row();
$result = $this->makeService()->delete($template);
$this->assertSame('not_registered', $result['kakao_skip_reason']);
Http::assertNothingSent();
}
public function test_휴면_해제는_release_후_상태를_재동기화한다(): void
{
Http::fake([
'*template/release*' => Http::response($this->kapiResponse()),
'*template/detail*' => Http::response($this->kapiResponse('200', [
'data' => ['templateCode' => 'g7_deadbeef_1', 'serviceStatus' => 'ACT'],
])),
]);
$template = $this->row([
'status' => BizppurioTemplateStatus::Dormant->value,
'template_code' => 'g7_deadbeef_1',
'sender_key' => 'SK_SNAP',
'approved_content' => ['templateContent' => '본문'],
]);
$updated = $this->makeService()->releaseDormant($template);
$this->assertSame(BizppurioTemplateStatus::Approved, $updated->status, '해제 후 카카오 실제 상태(ACT)로 재동기화돼야 한다.');
}
public function test_휴면_상태가_아니면_해제가_거부된다(): void
{
Http::fake();
$template = $this->row();
$this->expectException(BizppurioTemplateStateException::class);
$this->makeService()->releaseDormant($template);
}
/**
* 요약 맵은 SMS 본문의 유무·미리보기를 **모델 판정 결과로** 내보낸다 (#597 라운드 5 R4).
*
* 화면이 로케일 맵을 직접 훑으면 발송 게이트와 규칙이 갈린다 — 실제로 행 하단은
* truthy 판정(공백만 있어도 "본문 있음")에 폴백 로케일을 'ko' 로 박아 두었고,
* 발송은 trim 비교에 config('app.fallback_locale') 를 쓰고 있었다. 같은 판정을
* 두 벌 두면 한쪽만 고쳐지고, 증상은 "화면엔 본문이 있는데 발송은 스킵" 뿐이다.
*
* @effects sms_body_presence_and_preview_resolved_by_server
*/
public function test_요약맵이_sms_본문_유무와_미리보기를_서버_판정으로_내보낸다(): void
{
BizppurioTemplate::create([
'notification_type' => 'welcome',
'sms_body' => ['ko' => ' [샵] 가입을 환영합니다. ', 'en' => 'Welcome!'],
]);
// 전 로케일이 공백뿐이면 발송은 스킵된다 — 화면도 "본문 없음" 이어야 한다.
BizppurioTemplate::create([
'notification_type' => 'order_completed',
'sms_body' => ['ko' => ' ', 'en' => ''],
]);
BizppurioTemplate::create([
'notification_type' => 'comment_added',
'sms_body' => null,
]);
$map = $this->makeService()->summaryMap();
$this->assertTrue($map['welcome']['has_sms_body']);
$this->assertSame(' [샵] 가입을 환영합니다. ', $map['welcome']['sms_body_preview']);
$this->assertFalse($map['order_completed']['has_sms_body'], '공백뿐인 본문은 발송되지 않는다 — 화면도 없음으로 본다');
$this->assertFalse($map['comment_added']['has_sms_body']);
// 모달 시딩용 원본 맵은 그대로 유지된다(요약이 원본을 대체하지 않는다).
// MySQL json 컬럼은 키를 (길이, 사전순)으로 정규화 저장하므로 키 순서는 비교하지 않는다(§13.1).
$this->assertEqualsCanonicalizing(['ko' => ' [샵] 가입을 환영합니다. ', 'en' => 'Welcome!'], $map['welcome']['sms_body']);
}
/**
* 미리보기 로케일 해석이 모델과 같은 체인을 탄다 (#597 라운드 5 R4).
*
* @effects sms_body_presence_and_preview_resolved_by_server
*/
public function test_요약맵_미리보기가_현재_로케일과_폴백_체인을_따른다(): void
{
BizppurioTemplate::create([
'notification_type' => 'welcome',
'sms_body' => ['ko' => '한국어 본문', 'en' => 'English body'],
]);
app()->setLocale('en');
$this->assertSame('English body', $this->makeService()->summaryMap()['welcome']['sms_body_preview']);
// 해당 로케일 본문이 없으면 fallback_locale 로 내려간다 (하드코딩 'ko' 가 아니다)
config(['app.fallback_locale' => 'en']);
app()->setLocale('ja');
$this->assertSame('English body', $this->makeService()->summaryMap()['welcome']['sms_body_preview']);
app()->setLocale('ko');
}
}
@@ -1,174 +0,0 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* KakaoTemplateContentResolver — 카카오 템플릿 내용 조회 + 캐시 검증 (B안 5-1).
*
* 발송 시 카카오 상세조회로 본문·버튼·요소 원천을 가져오되, template_code 단위로
* 캐시해 rate limit 을 억제한다. TTL=0 이면 캐시를 우회(매번 조회)하고, 조회 실패는
* null 을 반환한다(호출측이 발송 skip).
*/
class KakaoTemplateContentResolverTest extends PluginTestCase
{
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* 테스트 간 캐시 오염 방지 — 실제 확장 캐시 드라이버를 공유하므로 각 테스트 전 비운다.
* (같은 template_code 키를 여러 테스트가 재사용해 캐시 히트가 교차 오염되는 것을 막는다.)
*/
protected function setUp(): void
{
parent::setUp();
app(CacheInterface::class)->flush();
}
/**
* @param array<string, mixed> $settings
*/
private function resolver(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): KakaoTemplateContentResolver
{
$pluginSettings = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
// template_cache_minutes 조회 (기본 60분 → 내부에서 ×60 초)
$pluginSettings->shouldReceive('get')
->with(self::IDENTIFIER, 'template_cache_minutes', Mockery::any())
->andReturnUsing(fn ($id, $key, $default) => $settings['template_cache_minutes'] ?? $default);
$kakao = new BizppurioKakaoApiClient($pluginSettings);
$templates = new AlimtalkTemplateService($kakao, $pluginSettings);
// 실제 확장 캐시 드라이버 주입 (contextual binding 과 동일 계약)
$cache = app(CacheInterface::class);
return new KakaoTemplateContentResolver($templates, $cache, $pluginSettings);
}
/**
* 상세조회 응답의 원본 카카오 필드(templateContent/buttons)를 그대로 반환한다.
*/
public function test_템플릿_내용을_조회해_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => [
'templateCode' => 'TW_1',
'templateContent' => '#{name}님 주문 완료',
'buttons' => [
['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order}'],
],
'inspectionStatus' => 'APR',
'status' => 'A',
],
], 200),
]);
$content = $this->resolver()->resolve('TW_1');
$this->assertNotNull($content);
$this->assertSame('#{name}님 주문 완료', $content['templateContent']);
$this->assertSame('WL', $content['buttons'][0]['linkType']);
}
/**
* 같은 template_code 를 두 번 조회하면 두 번째는 캐시 히트 — kapi 호출은 1회뿐.
*/
public function test_두번째_조회는_캐시_히트로_kapi를_다시_부르지_않는다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200),
]);
$resolver = $this->resolver();
$resolver->resolve('TW_1');
$resolver->resolve('TW_1');
Http::assertSentCount(1);
}
/**
* TTL=0 이면 캐시를 우회 — 매 조회마다 kapi 를 부른다(항상 최신).
*/
public function test_ttl이_0이면_캐시를_우회해_매번_조회한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200),
]);
$resolver = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]);
$resolver->resolve('TW_1');
$resolver->resolve('TW_1');
Http::assertSentCount(2);
}
/**
* 조회 실패(고아 템플릿·장애)는 null 을 반환한다(호출측이 발송 skip).
*/
public function test_조회_실패시_null을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '7315', 'message' => '템플릿 없음'], 200),
]);
$this->assertNull($this->resolver()->resolve('GONE'));
}
/**
* rate limit(HTTP 429)이면 짧게 재시도한 뒤 성공하면 내용을 반환한다(조회 폭주 완화).
*/
public function test_rate_limit_429는_재시도해_성공하면_반환한다(): void
{
// 첫 응답 429 → 재시도 → 두 번째 성공.
Http::fakeSequence('kapi.ppurio.com/*')
->push(['code' => '5002', 'description' => 'too many requests'], 429)
->push([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200);
$content = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
->resolve('TW_1');
$this->assertNotNull($content);
$this->assertSame('본문', $content['templateContent']);
Http::assertSentCount(2);
}
/**
* 재시도해도 계속 429면 null 을 반환한다(무한 재시도 방지).
*/
public function test_rate_limit이_지속되면_null을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '5002', 'description' => 'too many requests'], 429),
]);
$this->assertNull(
$this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
->resolve('TW_1'),
);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
@@ -1,316 +0,0 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* NotificationBindingService — 연동 조회(all·approvedTemplates)·저장(bind·unbind·applyFromTemplateSave) 검증.
*
* Phase 6 재설계 A: 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다. 이 서비스는 코어 편집
* 모달 전용 칸이 소비하는 조회(현재 연동 맵·승인 템플릿 드롭다운)와 코어 [저장] 훅이 호출하는
* bind/unbind 를 제공한다. (회원 대상 판정·template 증강은 SeedChannelTemplatesListener 로 이동.)
*/
class NotificationBindingServiceTest extends PluginTestCase
{
/**
* kapi 목록 반환값을 지정한 AlimtalkTemplateService mock 을 만듭니다.
*
* @param array<int, array<string, mixed>> $templates list()['templates'] 반환값
*/
private function fakeTemplateService(array $templates): AlimtalkTemplateService
{
$service = Mockery::mock(AlimtalkTemplateService::class);
$service->shouldReceive('list')->andReturn(['templates' => $templates, 'pagination' => []]);
return $service;
}
private function makeService(array $templates = [], ?KakaoTemplateContentResolver $kakaoContent = null): NotificationBindingService
{
return new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$this->fakeTemplateService($templates),
$kakaoContent ?? $this->fakeKakaoContent(),
);
}
/**
* clearMany 호출 인자를 기록하는 KakaoTemplateContentResolver mock 을 만듭니다.
*/
private function fakeKakaoContent(): KakaoTemplateContentResolver
{
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('clearMany')
->andReturnUsing(fn (array $codes) => count(array_unique(array_filter($codes))));
return $resolver;
}
public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_1234',
'template_name' => '주문완료',
'fallback_sms_enabled' => true,
'is_active' => true,
]);
$map = $this->makeService()->all();
$this->assertArrayHasKey('order_confirmed', $map);
$this->assertSame('TW_1234', $map['order_confirmed']['template_code']);
$this->assertSame('주문완료', $map['order_confirmed']['template_name']);
$this->assertTrue($map['order_confirmed']['fallback_sms_enabled']);
}
public function test_all은_연동이_없으면_빈_맵을_반환한다(): void
{
$this->assertSame([], $this->makeService()->all());
}
public function test_all_withAvailability는_승인목록에_있는_연동을_사용가능으로_표시한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_LIVE',
'template_name' => '살아있음',
'is_active' => true,
]);
// 승인 목록에 TW_LIVE 가 있음 → 사용 가능(is_unavailable=false)
$map = $this->makeService([
['templateCode' => 'TW_LIVE', 'templateName' => '살아있음', 'serviceStatus' => 'ACT'],
])->all(withAvailability: true);
$this->assertFalse($map['order_confirmed']['is_unavailable']);
}
public function test_all_withAvailability는_승인목록에_없는_연동을_사용불가로_표시한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_GONE',
'template_name' => '삭제됨',
'is_active' => true,
]);
// 승인 목록에 TW_GONE 이 없음(삭제·차단·미승인) → 사용 불가(is_unavailable=true)
$map = $this->makeService([
['templateCode' => 'TW_OTHER', 'templateName' => '다른템플릿', 'serviceStatus' => 'ACT'],
])->all(withAvailability: true);
$this->assertTrue($map['order_confirmed']['is_unavailable']);
}
public function test_all_withAvailability는_카카오_조회_실패시_소실판정을_생략한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_1234',
'template_name' => '주문완료',
'is_active' => true,
]);
// 카카오 승인 목록 조회가 실패(자격증명 미설정·장애)하면 판정을 건너뛴다 —
// 살아있는 연동이 일시 장애로 "사용 불가"로 오탐되지 않게(is_unavailable 필드 미부여).
$throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
$throwingTemplates->shouldReceive('list')
->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
$service = new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$throwingTemplates,
$this->fakeKakaoContent(),
);
$map = $service->all(withAvailability: true);
$this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed'], '조회 실패 시 소실 판정을 생략해야 한다.');
}
public function test_all은_기본값에서_소실판정을_하지_않는다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_1234',
'template_name' => '주문완료',
'is_active' => true,
]);
// store(저장) 경로처럼 프리필만 필요하면 카카오 대조를 생략한다(is_unavailable 필드 없음).
$map = $this->makeService()->all();
$this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed']);
}
public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void
{
$service = $this->makeService([
['templateCode' => 'TW_A', 'templateName' => '승인됨', 'serviceStatus' => 'ACT'],
['templateCode' => 'TW_B', 'templateName' => '발송전', 'serviceStatus' => 'RDY'],
['templateCode' => 'TW_C', 'templateName' => '검수중', 'serviceStatus' => 'REQ'],
['templateCode' => 'TW_D', 'templateName' => '반려', 'serviceStatus' => 'REJ'],
]);
$codes = array_column($service->approvedTemplates(), 'template_code');
$this->assertContains('TW_A', $codes, 'ACT(정상)는 노출되어야 한다.');
$this->assertContains('TW_B', $codes, 'RDY(발송전)는 노출되어야 한다.');
$this->assertNotContains('TW_C', $codes, 'REQ(검수중)는 제외되어야 한다.');
$this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.');
}
/**
* bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture).
*
* @param array<int, string> $approvedCodes 승인(발송 가능) 처리할 template_code 목록
*/
private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService
{
return $this->makeService(array_map(
fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
$approvedCodes,
));
}
public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void
{
$service = $this->makeServiceWithApprovedCodes(['TW_1236']);
$service->bind('welcome', [
'template_code' => 'TW_1236',
'template_name' => '가입환영',
'fallback_sms_enabled' => false,
]);
$this->assertDatabaseHas('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
'template_code' => 'TW_1236',
'is_active' => true,
]);
$service->unbind('welcome');
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
]);
}
public function test_bind는_같은_알림에_대해_갱신한다(): void
{
$service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']);
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']);
$service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']);
$this->assertDatabaseCount('bizppurio_notification_bindings', 1);
$this->assertDatabaseHas('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_2',
]);
}
public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void
{
// 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를
// 직접 호출하면 미승인 템플릿도 저장되던 결함.
$service = $this->makeServiceWithApprovedCodes(['TW_OTHER']);
$this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
try {
$service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']);
} finally {
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
]);
}
}
public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void
{
// 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지).
$throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
$throwingTemplates->shouldReceive('list')
->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
$service = new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$throwingTemplates,
$this->fakeKakaoContent(),
);
$this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
try {
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']);
} finally {
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_1',
]);
}
}
public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void
{
$this->makeServiceWithApprovedCodes(['TW_9'])
->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true);
$this->assertDatabaseHas('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
'template_code' => 'TW_9',
'fallback_sms_enabled' => true,
]);
}
public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void
{
$service = $this->makeServiceWithApprovedCodes(['TW_1']);
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']);
// 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님)
$service->applyFromTemplateSave('welcome', '', null, false);
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'channel' => 'alimtalk',
]);
}
public function test_캐시초기화는_연결된_모든_template_code를_resolver에_넘긴다(): void
{
// 서로 다른 알림에 두 템플릿을 연결.
$repo = new BizppurioNotificationBindingRepository;
$repo->upsert('order_confirmed', 'alimtalk', ['template_code' => 'TW_1', 'template_name' => 'A', 'is_active' => true]);
$repo->upsert('welcome', 'alimtalk', ['template_code' => 'TW_2', 'template_name' => 'B', 'is_active' => true]);
// resolver 가 실제로 받은 코드 목록을 포착.
$received = null;
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('clearMany')
->once()
->andReturnUsing(function (array $codes) use (&$received) {
$received = $codes;
return count($codes);
});
$cleared = $this->makeService([], $resolver)->clearTemplateContentCache();
$this->assertSame(2, $cleared);
$this->assertContains('TW_1', $received);
$this->assertContains('TW_2', $received);
}
public function test_캐시초기화는_연동이_없으면_0을_반환한다(): void
{
$this->assertSame(0, $this->makeService()->clearTemplateContentCache());
}
}
@@ -2,20 +2,24 @@
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Models\NotificationTemplate;
use App\Models\NotificationDefinition;
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 PHPUnit\Framework\Attributes\DataProvider;
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioTemplateRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
@@ -23,44 +27,47 @@ use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* SmsChannelDriver — 전화번호 해석·템플릿 게이트·SMS/LMS 판별·Job 위임 검증.
* SmsChannelDriver — 전화번호 해석·sms_body 게이트·#{var} 치환·SMS/LMS 판별·Job 위임 검증
* (#597 §3.5 — 본문 소스가 코어 템플릿에서 bizppurio_templates.sms_body 로 전환됨).
*
* 발송 Job dispatch 를 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버).
*/
class SmsChannelDriverTest extends PluginTestCase
{
/**
* 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다.
* sms_body 를 담은 템플릿 행을 만듭니다.
*
* Eloquent 이벤트 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신
* Mockery mock 을 사용한다(is_active + replaceVariables 만 stub).
* sms_body 는 로케일 맵이다(#597 §14.3). 문자열을 넘기면 ko 본문으로 취급한다 —
* 로케일 축과 무관한 기존 케이스가 맵 표기를 반복하지 않게 하기 위한 편의다.
*
* @param array<string, string> $rendered replaceVariables 반환값
* @param string|array<string, string>|null $smsBody 로케일 맵 또는 ko 본문
*/
private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '주문이 완료되었습니다.']): NotificationTemplate
{
$template = Mockery::mock(NotificationTemplate::class)->makePartial();
$template->is_active = true;
$template->shouldReceive('replaceVariables')->andReturn($rendered);
private function templateRow(
string|array|null $smsBody = '주문이 완료되었습니다.',
bool $isActive = true,
bool $smsOnly = false,
): BizppurioTemplate {
$row = new BizppurioTemplate;
$row->notification_type = 'order_confirmed';
$row->status = BizppurioTemplateStatus::Draft;
$row->sms_body = is_string($smsBody) ? ['ko' => $smsBody] : $smsBody;
$row->sms_only = $smsOnly;
$row->is_active = $isActive;
return $template;
return $row;
}
/**
* 템플릿 resolve 결과를 지정한 driver 를 만듭니다.
*
* @param NotificationTemplate|null $template resolve 반환값
* @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용)
* findByType 반환값을 지정한 driver 를 만듭니다.
*/
private function makeDriver(
?NotificationTemplate $template,
?BizppurioTemplate $row,
?MessagePayloadBuilder $builder = null,
bool $isTestMode = true,
?NotificationDefinition $definition = null,
): SmsChannelDriver {
$templateService = Mockery::mock(NotificationTemplateService::class);
$templateService->shouldReceive('resolve')
->with(Mockery::any(), 'sms')
->andReturn($template);
$templates = Mockery::mock(BizppurioTemplateRepositoryInterface::class);
$templates->shouldReceive('findByType')->andReturn($row);
$pluginSettings = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')
@@ -69,11 +76,12 @@ class SmsChannelDriverTest extends PluginTestCase
// 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백.
$definitionService = Mockery::mock(NotificationDefinitionService::class);
$definitionService->shouldReceive('resolve')->andReturn(null);
$definitionService->shouldReceive('resolve')->andReturn($definition);
return new SmsChannelDriver(
$templateService,
$definitionService,
$templates,
new AlimtalkPayloadMapper,
new SmsTypeResolver,
$builder ?? $this->spyBuilder(),
new BizppurioDispatchRepository,
@@ -114,7 +122,10 @@ class SmsChannelDriverTest extends PluginTestCase
return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce');
}
public function test_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void
/**
* @effects sms_skipped_when_body_empty_or_row_missing
*/
public function test_템플릿_행이_없으면_예외를_던지고_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
@@ -125,56 +136,81 @@ class SmsChannelDriverTest extends PluginTestCase
$this->makeDriver(null)->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
$this->assertDatabaseCount('bizppurio_dispatches', 0);
}
}
public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void
/**
* @effects sms_skipped_when_body_empty_or_row_missing
*/
public function test_sms_body가_비어있으면_예외를_던지고_발송하지_않는다(): 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,
);
$this->expectException(NotificationSendSkippedException::class);
try {
$driver->send($member, $this->notification());
$this->fail('NotificationSendSkippedException 가 발생해야 한다.');
} catch (NotificationSendSkippedException $e) {
$this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.');
$this->assertStringNotContainsString('order_confirmed', $e->getMessage());
$this->makeDriver($this->templateRow(smsBody: ' '))->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
}
public function test_비활성_행이면_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->templateRow(isActive: false))->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
}
/**
* sms_only 는 알림톡을 끄는 플래그지 SMS 를 켜거나 끄는 플래그가 아니다 (#597 §3.5).
*
* 두 값을 모두 태워야 "무관" 이 증명된다 — 한쪽만(특히 기본값과 같은 false 만) 넣으면
* 이후 누군가 SmsChannelDriver 에 sms_only 분기를 넣어도 이 테스트는 green 으로 남는다.
*
* @effects sms_dispatches_regardless_of_sms_only_flag
*/
#[DataProvider('smsOnlyProvider')]
public function test_sms_body가_있으면_sms_only_값과_무관하게_발송한다(bool $smsOnly): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->templateRow(smsOnly: $smsOnly))->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
}
/**
* @return array<string, array{bool}>
*/
public static function smsOnlyProvider(): array
{
return [
'sms_only 꺼짐 (알림톡 병행)' => [false],
'sms_only 켜짐 (문자 단독)' => [true],
];
}
public function test_회원은_mobile로_발송한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$builder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification());
$this->makeDriver($this->templateRow(), $builder)->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
$this->assertCount(1, $builder->calls);
$this->assertSame('01012345678', $builder->calls[0]['to'], '하이픈 제거된 회원 mobile 로 발송해야 한다.');
$this->assertSame('01012345678', $builder->calls[0]['to']);
}
public function test_비회원은_data의_전화번호로_발송한다(): void
@@ -184,24 +220,162 @@ class SmsChannelDriverTest extends PluginTestCase
$data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
$builder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(), $builder)->send($guest, $this->notification($data));
$this->makeDriver($this->templateRow(), $builder)->send($guest, $this->notification($data));
Bus::assertDispatched(SendMessageJob::class);
$this->assertSame('01099990000', $builder->calls[0]['to']);
}
/**
* @effects sms_body_is_dispatch_source_not_core_template
*/
public function test_sms_body의_변수를_알림_data로_치환해_발송한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$builder = $this->spyBuilder();
// sms_body 는 알림톡 본문과 동일한 #{var} 표기를 쓴다 (#597 §3.1).
$this->makeDriver($this->templateRow(smsBody: '[샵] #{name}님 #{order_number} 주문 완료'), $builder)
->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
$this->assertSame('[샵] 김철수님 A1 주문 완료', $builder->calls[0]['message']);
}
/**
* 수신자 로케일별 본문 선택 (#597 §14.3).
*
* 개편 과정에서 sms_body 가 단일 문자열이 되며 이 축이 사라진 적이 있다 — 코드에서
* 로케일 해석을 빼도 어떤 테스트도 red 가 되지 않았고, 다국어 사이트의 en/ja 수신자가
* ko 본문을 받는 것이 유일한 증상이었다. 이 테스트가 그 축을 고정한다.
*
* @effects sms_body_rendered_in_recipient_locale
*/
#[DataProvider('recipientLocaleProvider')]
public function test_sms_body는_수신자_로케일로_렌더된다(string $locale, string $expected): void
{
Bus::fake();
$guest = new GuestNotifiable('guest@example.com', '홍길동', $locale);
$builder = $this->spyBuilder();
$this->makeDriver($this->templateRow(smsBody: [
'ko' => '[샵] 주문이 완료되었습니다.',
'en' => '[Shop] Your order is complete.',
]), $builder)->send($guest, $this->notification([
SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000',
]));
$this->assertSame($expected, $builder->calls[0]['message']);
}
/**
* @return array<string, array{0: string, 1: string}>
*/
public static function recipientLocaleProvider(): array
{
return [
'ko 수신자' => ['ko', '[샵] 주문이 완료되었습니다.'],
'en 수신자' => ['en', '[Shop] Your order is complete.'],
// 본문이 없는 로케일은 fallback_locale(ko)로 발송한다 — 빈 문자열로 스킵하지 않는다.
'본문 없는 로케일 → ko 폴백' => ['ja', '[샵] 주문이 완료되었습니다.'],
];
}
/**
* LMS 제목도 본문과 같은 수신자 로케일로 렌더된다 (#597 §15.1).
*
* 본문만 로케일을 따르고 제목이 앱 로케일이면 한 통 안에서 언어가 갈린다. 이 축은
* 라운드 5 까지 무단언이었다 — effect 이름은 "…_definition_label_subject" 인데 어느
* 테스트도 subject 를 읽지 않아, resolveTypeLabel 의 $locale 인자를 지워도 green 이었다.
*
* @effects sms_lms_split_by_body_bytes_with_definition_label_subject, sms_body_rendered_in_recipient_locale
*/
public function test_lms_제목도_수신자_로케일로_렌더된다(): void
{
Bus::fake();
$definition = new NotificationDefinition;
$definition->forceFill([
'type' => 'order_completed',
'name' => ['ko' => '주문 완료', 'en' => 'Order completed'],
]);
foreach ([['ko', '주문 완료'], ['en', 'Order completed']] as [$locale, $expected]) {
$builder = $this->spyBuilder();
$guest = new GuestNotifiable('guest@example.com', '홍길동', $locale);
// 바이트 길이로 LMS 분기가 되도록 두 로케일 본문을 모두 길게 둔다.
$this->makeDriver($this->templateRow(smsBody: [
'ko' => str_repeat('가', 100),
'en' => str_repeat('a', 300),
]), $builder, true, $definition)->send($guest, $this->notification([
SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000',
]));
$this->assertSame('lms', $builder->calls[0]['type']);
$this->assertSame($expected, $builder->calls[0]['subject'], "{$locale} 수신자의 LMS 제목");
}
}
/**
* 행 게이트는 맵 전체를 보고, 본문 선택은 로케일별로 다시 판정한다 (#597 §14.3).
*
* 두 판정은 층이 다르다. 행 게이트(hasSmsBody)가 "현재 앱 로케일의 본문" 만 보면
* ko 만 채운 알림이 en 관리자 세션에서 "본문 없음" 으로 잘못 판정된다. 반대로 행
* 게이트만 통과시키고 끝내면 그 수신자의 로케일에 본문이 없을 때 빈 SMS 가 나간다.
*
* 로케일 해석은 코어 NotificationContentBehavior 와 동일한 체인이다 —
* `수신자 로케일 → fallback_locale → ''`. fallback_locale(ko) 이 비어 있으면
* 발송하지 않는다(개편 전 코어 템플릿 경로와 같은 결과).
*/
public function test_fallback_locale에_본문이_없으면_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
// en 만 채운 행 — 행 게이트(hasSmsBody)는 통과하지만 ko 수신자용 본문이 없다.
$row = $this->templateRow(smsBody: ['en' => '[Shop] Done']);
$this->assertTrue($row->hasSmsBody(), '행 게이트는 어느 로케일에든 본문이 있으면 통과한다.');
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($row)->send($member, $this->notification());
} finally {
Bus::assertNothingDispatched();
}
}
/**
* 모든 로케일이 빈 문자열이면 발송하지 않는다 (#597 §14.3).
*
* 맵이 존재한다는 사실만으로 게이트를 통과하면 빈 SMS 가 나간다.
*/
public function test_모든_로케일이_비어있으면_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->templateRow(smsBody: ['ko' => ' ', 'en' => '']))
->send($member, $this->notification());
} finally {
Bus::assertNothingDispatched();
}
}
public function test_회원_발송_시_pending_이력을_생성하고_회원id를_기록한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']);
$this->makeDriver($this->fakeTemplate())->send($member, $this->notification());
$this->makeDriver($this->templateRow())->send($member, $this->notification());
$this->assertDatabaseCount('bizppurio_dispatches', 1);
$dispatch = BizppurioDispatch::first();
$this->assertSame('pending', $dispatch->status->value);
$this->assertSame('01012345678', $dispatch->to_number);
$this->assertSame($member->id, $dispatch->to_user_id, '회원 발송은 to_user_id 를 채워야 한다.');
$this->assertSame($member->id, $dispatch->to_user_id);
$this->assertSame('order_confirmed', $dispatch->notification_type);
}
@@ -211,61 +385,23 @@ class SmsChannelDriverTest extends PluginTestCase
$guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
$data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
$this->makeDriver($this->fakeTemplate())->send($guest, $this->notification($data));
$this->makeDriver($this->templateRow())->send($guest, $this->notification($data));
$dispatch = BizppurioDispatch::first();
$this->assertNotNull($dispatch);
$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 컬럼과 중복이라 제외돼야 한다.');
$this->assertNull(BizppurioDispatch::first()->to_user_id);
}
public function test_발송하지_않으면_이력도_생성하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// 템플릿 없음 → NotificationSendSkippedException → 이력도 없어야 함
$this->expectException(NotificationSendSkippedException::class);
$guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
try {
$this->makeDriver(null)->send($member, $this->notification());
} finally {
$this->assertDatabaseCount('bizppurio_dispatches', 0);
$this->makeDriver($this->templateRow())->send($guest, $this->notification());
} catch (NotificationSendSkippedException) {
// 전화번호 없음 skip — 이력 미생성 검증이 목적
}
$this->assertDatabaseCount('bizppurio_dispatches', 0);
}
public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void
@@ -273,81 +409,50 @@ class SmsChannelDriverTest extends PluginTestCase
Bus::fake();
$guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
// data 에 전화번호 없음 + 게스트라 mobile 속성 없음
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([]));
$this->makeDriver($this->templateRow())->send($guest, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
}
public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void
/**
* @effects sms_lms_split_by_body_bytes_with_definition_label_subject
*/
public function test_짧은_본문은_sms로_긴_본문은_lms로_보낸다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
// SMS (90 byte 이하)
$smsBuilder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => '짧은 본문']), $smsBuilder)
$shortBuilder = $this->spyBuilder();
$this->makeDriver($this->templateRow(smsBody: '짧은 본문'), $shortBuilder)
->send($member, $this->notification());
$this->assertSame('sms', $smsBuilder->calls[0]['type']);
$this->assertSame('sms', $shortBuilder->calls[0]['type']);
// LMS (90 byte 초과 — 한글 45자 이상 = EUC-KR 90byte 초과)
$longBody = str_repeat('가', 100);
$lmsBuilder = $this->spyBuilder();
$this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => $longBody]), $lmsBuilder)
$longBuilder = $this->spyBuilder();
$this->makeDriver($this->templateRow(smsBody: str_repeat('가', 100)), $longBuilder)
->send($member, $this->notification());
$this->assertSame('lms', $lmsBuilder->calls[0]['type']);
$this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.');
}
public function test_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$this->expectException(NotificationSendSkippedException::class);
try {
$this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' ']))
->send($member, $this->notification());
} finally {
Bus::assertNotDispatched(SendMessageJob::class);
}
$this->assertSame('lms', $longBuilder->calls[0]['type']);
}
public function test_generic_notification이_아니면_무시한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$other = new Notification;
$this->makeDriver($this->fakeTemplate())->send($member, $other);
$this->makeDriver($this->templateRow())->send($member, new Notification);
Bus::assertNotDispatched(SendMessageJob::class);
}
public function test_정상_조건에서_발송하고_이력을_생성한다(): void
{
// 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
// 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate())->send($member, $this->notification());
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->makeDriver($this->templateRow(), isTestMode: true)->send($member, $this->notification());
$this->assertTrue(BizppurioDispatch::first()->is_test_mode);
}
@@ -357,7 +462,7 @@ class SmsChannelDriverTest extends PluginTestCase
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate(), isTestMode: false)->send($member, $this->notification());
$this->makeDriver($this->templateRow(), isTestMode: false)->send($member, $this->notification());
$this->assertFalse(BizppurioDispatch::first()->is_test_mode);
}

Some files were not shown because too many files have changed in this diff Show More