diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 3dacc8e4..ea430054 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -30,4 +30,15 @@ return [ 'permanent_failure' => 'Permanent Failure', 'balance_low' => 'Insufficient Balance', ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + 'error' => [ + 'credentials_missing' => 'Please set the Bizppurio ID and password first.', + 'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.', + 'send_failed' => 'Failed to send the message.', + 'send_retryable' => 'Message delivery temporarily failed. (code: :code)', + 'invalid_response' => 'Unable to parse the Bizppurio response.', + '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.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index ae9112c2..bf8e501f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -30,4 +30,15 @@ return [ 'permanent_failure' => '영구 실패', 'balance_low' => '잔액 부족', ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + 'error' => [ + 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', + 'token_issue_failed' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.', + 'send_failed' => '메시지 발송 요청에 실패했습니다.', + 'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)', + 'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.', + 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', + 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php new file mode 100644 index 00000000..cceb808b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php @@ -0,0 +1,60 @@ +resultCode; + } + + /** + * HTTP 상태 코드를 반환합니다. + * + * @return int|null HTTP 상태 코드, 없으면 null + */ + public function getHttpStatus(): ?int + { + return $this->httpStatus; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php new file mode 100644 index 00000000..244e9c38 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -0,0 +1,114 @@ + $payload 발송 payload (MessagePayloadBuilder 조립) + * @param string $refkey 우리 부여 키 (이력 매칭용) + */ + public function __construct( + public readonly array $payload, + public readonly string $refkey, + ) { + $this->afterCommit = true; + } + + /** + * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다. + * + * @param BizppurioApiClient $client 발송 API 클라이언트 + * + * @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거) + */ + public function handle(BizppurioApiClient $client): void + { + $result = $client->sendMessage($this->payload); + $code = (string) ($result['code'] ?? ''); + + if ($client->isSuccess($result)) { + // 발송 접수 성공. 이력 갱신(messagekey 저장)은 Phase 4 훅이 담당. + return; + } + + if (in_array($code, self::RETRYABLE_CODES, true)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.send_retryable', ['code' => $code]), + resultCode: $code, + ); + } + + // 영구 실패: 재시도하지 않고 종료. 실패 이력 기록은 Phase 4 가 담당. + Log::warning('비즈뿌리오 발송 영구 실패', [ + 'refkey' => $this->refkey, + 'code' => $code, + 'description' => $result['description'] ?? null, + ]); + } + + /** + * 재시도 사이 대기 시간(초)을 반환합니다. + * + * 429·일시오류 재시도 시 짧게 backoff 한다. sync 실행 시에도 이 값이 적용된다. + * + * @return int 대기 초 + */ + public function backoff(): int + { + return self::SYNC_RETRY_MAX_WAIT_SECONDS; + } + + /** + * 모든 재시도 소진 후 최종 실패 시 로그를 남깁니다. + * + * @param Throwable $exception 마지막으로 발생한 예외 + */ + public function failed(Throwable $exception): void + { + Log::error('비즈뿌리오 발송 Job 최종 실패', [ + 'refkey' => $this->refkey, + 'error' => $exception->getMessage(), + 'result_code' => $exception instanceof BizppurioApiException + ? $exception->getResultCode() + : null, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 6e19a775..2ef09e01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; +use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; /** * 비즈뿌리오 메시징 플러그인 서비스 프로바이더. @@ -36,7 +37,7 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * @var array */ protected array $cacheServices = [ - // Phase 2: BizppurioTokenService::class + BizppurioTokenService::class, ]; /** @@ -44,8 +45,6 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()` * (= 플러그인 식별자) 네임스페이스로 로드한다. - * - * @return void */ protected function loadExtensionTranslations(): void { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php new file mode 100644 index 00000000..4b915b1a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -0,0 +1,141 @@ + $payload MessagePayloadBuilder 가 조립한 발송 본문 + * @return array 응답 배열 (code/description/messagekey/refkey) + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + public function sendMessage(array $payload): array + { + $result = $this->postMessage($payload, $this->tokenService->getToken()); + + // 토큰·인증 무효 → 재발급 후 1회 재시도 + if (in_array((string) ($result['code'] ?? ''), self::AUTH_INVALID_CODES, true)) { + $result = $this->postMessage($payload, $this->tokenService->refreshToken()); + } + + return $result; + } + + /** + * 발송 응답 결과코드가 성공(1000)인지 판정합니다. + * + * @param array $result sendMessage 응답 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * 단일 발송 요청을 수행합니다. + * + * @param array $payload 발송 본문 + * @param string $token Bearer 액세스 토큰 + * @return array 응답 배열 + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + private function postMessage(array $payload, string $token): array + { + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl().'/v3/message', $payload); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.send_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(): string + { + $environment = (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'environment', 'dev'); + + return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php new file mode 100644 index 00000000..1404fcff --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -0,0 +1,187 @@ + 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getSenderProfiles(): array + { + return $this->post('/v3/kakao/profile/use'); + } + + /** + * 알림톡 템플릿 목록을 조회합니다. (`/v3/kakao/template/list`) + * + * @param string $senderKey 발신프로필 키 + * @param array $params 추가 조회 파라미터 (count/page/status 등) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateList(string $senderKey, array $params = []): array + { + return $this->post('/v3/kakao/template/list', array_merge($params, [ + 'senderKey' => $senderKey, + ])); + } + + /** + * 알림톡 템플릿 상세를 조회합니다. (`/v3/kakao/template/detail`) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateDetail(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/detail', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. + * + * Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는 + * 자동으로 주입되므로 도메인 파라미터만 전달한다. + * + * @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add') + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function request(string $path, array $params = []): array + { + return $this->post($path, $params); + } + + /** + * 카카오 관리 API 응답이 성공(200)인지 판정합니다. + * + * @param array $result 응답 배열 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * bizId/apiKey 를 주입해 카카오 관리 API 를 POST 호출합니다. + * + * @param string $path 엔드포인트 경로 + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function post(string $path, array $params = []): array + { + [$bizId, $apiKey] = $this->credentials(); + + $response = $this->http() + ->withHeaders(['Content-Type' => 'application/json; charset=utf-8']) + ->post(self::BASE_URL.$path, array_merge($params, [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ])); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + + /** + * 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다. + * + * @return array{0: string, 1: string} [bizId, apiKey] + * + * @throws BizppurioApiException 자격증명 미설정 시 + */ + private function credentials(): array + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $bizId = (string) ($settings['bizppurio_id'] ?? ''); + $apiKey = (string) ($settings['api_key'] ?? ''); + + if ($bizId === '' || $apiKey === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_credentials_missing'), + ); + } + + return [$bizId, $apiKey]; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php new file mode 100644 index 00000000..12f73b30 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -0,0 +1,160 @@ +cache->remember( + self::CACHE_KEY, + fn (): string => $this->issueToken(), + self::CACHE_TTL_SECONDS, + ); + } + + /** + * 캐시된 토큰을 무효화하고 새 토큰을 발급받아 반환합니다. + * + * 발송 응답이 3002/3005(토큰·인증 무효)일 때 호출한다. + * + * @return string 새로 발급받은 Bearer 액세스 토큰 + * + * @throws BizppurioApiException 토큰 재발급 실패 시 + */ + public function refreshToken(): string + { + $this->cache->forget(self::CACHE_KEY); + + return $this->getToken(); + } + + /** + * 캐시된 토큰을 제거합니다. + */ + public function forget(): void + { + $this->cache->forget(self::CACHE_KEY); + } + + /** + * `/v1/token` 을 호출해 새 토큰을 발급받습니다. + * + * @return string Bearer 액세스 토큰 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function issueToken(): string + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $account = (string) ($settings['bizppurio_id'] ?? ''); + $password = (string) ($settings['password'] ?? ''); + + if ($account === '' || $password === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.credentials_missing'), + ); + } + + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Basic '.base64_encode($account.':'.$password), + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl($settings).'/v1/token'); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.token_issue_failed'), + httpStatus: $response->status(), + ); + } + + $token = (string) ($response->json('accesstoken') ?? ''); + + if ($token === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.token_issue_failed'), + httpStatus: $response->status(), + ); + } + + return $token; + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @param array $settings 플러그인 환경설정 + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(array $settings): string + { + $environment = (string) ($settings['environment'] ?? 'dev'); + + return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php new file mode 100644 index 00000000..2de0bace --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php @@ -0,0 +1,146 @@ + 발송 payload + */ + public function buildSms(string $to, string $message, string $refkey): array + { + return $this->withCommon('sms', $to, $refkey, [ + 'sms' => [ + 'message' => $message, + ], + ]); + } + + /** + * LMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 2000byte, subject 최대 64byte) + * + * @param string $to 수신 전화번호 + * @param string $message 본문 + * @param string $refkey 우리 부여 키 + * @param string|null $subject 제목(코어 subject 재사용). 없으면 생략 + * @return array 발송 payload + */ + public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array + { + $lms = ['message' => $message]; + + if ($subject !== null && $subject !== '') { + $lms['subject'] = $subject; + } + + return $this->withCommon('lms', $to, $refkey, [ + 'lms' => $lms, + ]); + } + + /** + * 알림톡(AT) 발송 payload 를 조립합니다. (message: 한/영 1300자) + * + * senderkey 는 환경설정 sender_key 를 사용한다. 버튼/바로연결 등 유형별 선택 + * 필드는 $extra 로 병합한다(템플릿에 포함된 경우 필수 — 채널 드라이버가 구성). + * + * @param string $to 수신 전화번호 + * @param string $templateCode 카카오 템플릿 코드 + * @param string $message 치환 완료된 본문 (#{변수} 형식) + * @param string $refkey 우리 부여 키 + * @param array $extra button/quickreply/title 등 유형별 선택 필드 + * @return array 발송 payload + */ + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $at = array_merge([ + 'senderkey' => $this->senderKey(), + 'templatecode' => $templateCode, + 'message' => $message, + ], $extra); + + return $this->withCommon('at', $to, $refkey, [ + 'at' => $at, + ]); + } + + /** + * 공통 필드(account/type/from/to/refkey)와 채널별 content 를 결합합니다. + * + * @param string $type 메시지 타입 (sms/lms/at) + * @param string $to 수신 전화번호 + * @param string $refkey 우리 부여 키 + * @param array $content 채널별 content 배열 + * @return array 발송 payload + */ + private function withCommon(string $type, string $to, string $refkey, array $content): array + { + return [ + 'account' => $this->account(), + 'type' => $type, + 'from' => $this->senderNumber(), + 'to' => $to, + 'refkey' => $refkey, + 'content' => $content, + ]; + } + + /** + * 발송 계정(account = bizppurio_id)을 반환합니다. + */ + private function account(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'bizppurio_id', ''); + } + + /** + * 발신 번호(from = sender_number)를 반환합니다. + */ + private function senderNumber(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_number', ''); + } + + /** + * 알림톡 발신프로필 키(senderkey = sender_key)를 반환합니다. + */ + private function senderKey(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', ''); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php new file mode 100644 index 00000000..84e01bbd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php @@ -0,0 +1,90 @@ +byteLength($message) <= self::SMS_MAX_BYTES + ? DispatchChannel::Sms + : DispatchChannel::Lms; + } + + /** + * 본문의 EUC-KR 기준 byte 길이를 반환합니다. + * + * 이모지 등 EUC-KR 미표현 문자를 제거한 뒤 변환하여 byte 를 측정한다. + * + * @param string $message 대상 문자열(UTF-8) + * @return int EUC-KR byte 길이 + */ + public function byteLength(string $message): int + { + $sanitized = $this->stripUnsupported($message); + $eucKr = mb_convert_encoding($sanitized, 'EUC-KR', 'UTF-8'); + + return strlen($eucKr); + } + + /** + * LMS 최대 byte 를 초과하는지 여부를 반환합니다. + * + * @param string $message 변수 치환이 끝난 최종 본문 + * @return bool LMS 한도(2000byte) 초과 시 true + */ + public function exceedsLmsLimit(string $message): bool + { + return $this->byteLength($message) > self::LMS_MAX_BYTES; + } + + /** + * EUC-KR 로 표현 불가능한 문자(이모지 등)를 제거합니다. + * + * mb_convert_encoding 은 미표현 문자를 '?' 로 바꿔 byte 를 왜곡시키므로, + * UTF-8 → EUC-KR → UTF-8 왕복 후 원본과 달라진(=치환된) 문자를 걸러낸다. + * + * @param string $message 대상 문자열(UTF-8) + * @return string EUC-KR 표현 가능한 문자만 남긴 문자열 + */ + private function stripUnsupported(string $message): string + { + $substituteBackup = mb_substitute_character(); + // 미표현 문자를 삭제(none)하도록 설정해 왜곡 없이 제거 + mb_substitute_character('none'); + + try { + $eucKr = mb_convert_encoding($message, 'EUC-KR', 'UTF-8'); + + return mb_convert_encoding($eucKr, 'UTF-8', 'EUC-KR'); + } finally { + mb_substitute_character($substituteBackup); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 979844b5..3cf345c5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; +use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -24,8 +25,6 @@ abstract class PluginTestCase extends TestCase /** * 테스트 환경 설정 - * - * @return void */ protected function setUp(): void { @@ -36,8 +35,6 @@ abstract class PluginTestCase extends TestCase /** * tearDown 에 HookManager 상태 복원. - * - * @return void */ protected function tearDown(): void { @@ -48,12 +45,10 @@ abstract class PluginTestCase extends TestCase /** * HookManager static $hooks / $filters / $dispatching 를 스냅샷. - * - * @return void */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ 'hooks' => $ref->getProperty('hooks')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(), @@ -63,8 +58,6 @@ abstract class PluginTestCase extends TestCase /** * 스냅샷 시점으로 HookManager 복원. - * - * @return void */ private function restoreHookManager(): void { @@ -72,7 +65,7 @@ abstract class PluginTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php new file mode 100644 index 00000000..45fba633 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -0,0 +1,88 @@ + 'acct', 'type' => 'sms', 'from' => '070', 'to' => '010', + 'refkey' => 'ref1', 'content' => ['sms' => ['message' => 'hi']], + ]; + + /** + * sendMessage 응답을 반환하는 ApiClient mock 을 만듭니다. + * + * @param array $result sendMessage 반환값 + */ + private function makeClient(array $result): BizppurioApiClient + { + $mock = Mockery::mock(BizppurioApiClient::class); + $mock->shouldReceive('sendMessage')->with($this->payload)->andReturn($result); + $mock->shouldReceive('isSuccess') + ->andReturnUsing(fn ($r) => (string) ($r['code'] ?? '') === '1000'); + + return $mock; + } + + public function test_성공시_예외없이_종료(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk'])); + + // 예외 미발생 = 정상 완료 + $this->assertTrue(true); + } + + public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->expectException(BizppurioApiException::class); + + try { + $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp'])); + } catch (BizppurioApiException $e) { + $this->assertSame('5003', $e->getResultCode()); + throw $e; + } + } + + public function test_영구실패_결과코드는_예외없이_종료된다(): void + { + // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음) + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error'])); + + $this->assertTrue(true); + } + + public function test_tries와_backoff_기본값(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertSame(2, $job->tries); + $this->assertSame(2, $job->backoff()); + } + + public function test_after_commit_활성화(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertTrue($job->afterCommit); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php new file mode 100644 index 00000000..a3aa286b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -0,0 +1,133 @@ + 'acct', + 'type' => 'sms', + 'from' => '07012345678', + 'to' => '01011112222', + 'refkey' => 'ref1', + 'content' => ['sms' => ['message' => 'hi']], + ]; + + private function makeSettings(string $environment = 'dev'): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, 'environment', 'dev') + ->andReturn($environment); + + return $mock; + } + + /** + * getToken/refreshToken 을 순차 반환하는 TokenService mock. + * + * @param string $token getToken 반환 토큰 + * @param string|null $refreshed refreshToken 반환 토큰(null 이면 미기대) + */ + private function makeToken(string $token, ?string $refreshed = null): BizppurioTokenService + { + $mock = Mockery::mock(BizppurioTokenService::class); + $mock->shouldReceive('getToken')->andReturn($token); + + if ($refreshed !== null) { + $mock->shouldReceive('refreshToken')->once()->andReturn($refreshed); + } + + return $mock; + } + + public function test_발송_성공(): void + { + Http::fake([ + '*/v3/message' => Http::response([ + 'code' => 1000, 'description' => 'Success', 'refkey' => 'ref1', 'messagekey' => 'mk1', + ], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('TOKEN'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + $this->assertSame('mk1', $result['messagekey']); + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer TOKEN')); + } + + public function test_토큰무효_3002는_재발급_후_1회_재시도(): void + { + Http::fake([ + '*/v3/message' => Http::sequence() + ->push(['code' => 3002, 'description' => 'Invalid token'], 200) + ->push(['code' => 1000, 'description' => 'Success', 'messagekey' => 'mk2'], 200), + ]); + + $client = new BizppurioApiClient( + $this->makeToken('OLD', 'NEW'), + $this->makeSettings(), + ); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + Http::assertSentCount(2); + } + + public function test_운영_환경은_live_도메인_호출(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings('live')); + $client->sendMessage($this->payload); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com')); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v3/message' => Http::response([], 500)]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + $this->expectException(BizppurioApiException::class); + $client->sendMessage($this->payload); + } + + public function test_영구실패_결과코드는_그대로_반환(): void + { + Http::fake([ + '*/v3/message' => Http::response(['code' => 3006, 'description' => 'Account error'], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertFalse($client->isSuccess($result)); + $this->assertSame(3006, $result['code']); + // 3006 은 재시도 대상 아님 → HTTP 1회 + Http::assertSentCount(1); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php new file mode 100644 index 00000000..973b0dc1 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -0,0 +1,115 @@ + $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + private function client(): BizppurioKakaoApiClient + { + return new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => 'key01']), + ); + } + + public function test_발신프로필_조회는_bizid_apikey를_body에_싣는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['success' => []]], 200), + ]); + + $result = $this->client()->getSenderProfiles(); + + $this->assertTrue($this->client()->isSuccess($result)); + Http::assertSent(function ($request) { + return str_contains($request->url(), 'kapi.ppurio.com/v3/kakao/profile/use') + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_목록은_senderkey를_포함한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200), + ]); + + $this->client()->getTemplateList('SENDER_KEY', ['count' => 20]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/list') + && $request['senderKey'] === 'SENDER_KEY' + && $request['count'] === 20 + && $request['bizId'] === 'biz01'; + }); + } + + public function test_템플릿_상세_조회(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'TW_1']], 200), + ]); + + $result = $this->client()->getTemplateDetail('SK', 'TW_1'); + + $this->assertSame('TW_1', $result['data']['templateCode']); + } + + public function test_임의_엔드포인트_request_위임(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200'], 200), + ]); + + $this->client()->request('/v3/kakao/template/add', ['templateName' => 'T']); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/add') + && $request['templateName'] === 'T'); + } + + public function test_자격증명_미설정시_예외(): void + { + $client = new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $client->getSenderProfiles(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]); + + $this->expectException(BizppurioApiException::class); + $this->client()->getSenderProfiles(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php new file mode 100644 index 00000000..15260192 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -0,0 +1,149 @@ + $store 참조로 전달되는 캐시 저장소 + */ + private function makeCache(array &$store): CacheInterface + { + $mock = Mockery::mock(CacheInterface::class); + + $mock->shouldReceive('remember')->andReturnUsing( + function (string $key, callable $callback) use (&$store) { + if (! array_key_exists($key, $store)) { + $store[$key] = $callback(); + } + + return $store[$key]; + } + ); + + $mock->shouldReceive('forget')->andReturnUsing( + function (string $key) use (&$store) { + unset($store[$key]); + + return true; + } + ); + + return $mock; + } + + /** + * bizppurio_id/password/environment 를 반환하는 설정 mock. + * + * @param array $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + public function test_토큰_발급_후_캐시에서_재사용된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // 두 번째 호출은 캐시에서 → HTTP 1회만 + $this->assertSame('TOKEN_A', $service->getToken()); + Http::assertSentCount(1); + } + + public function test_refresh는_forget_후_재발급한다(): void + { + $tokens = ['TOKEN_A', 'TOKEN_B']; + Http::fake([ + '*/v1/token' => Http::sequence() + ->push(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200) + ->push(['accesstoken' => 'TOKEN_B', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // refresh → 캐시 무효화 후 새 토큰 + $this->assertSame('TOKEN_B', $service->refreshToken()); + Http::assertSentCount(2); + } + + public function test_검수_환경은_dev_도메인을_호출한다(): void + { + Http::fake([ + 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com')); + } + + public function test_자격증명_미설정시_예외(): void + { + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => '', 'password' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v1/token' => Http::response([], 500)]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php new file mode 100644 index 00000000..1963c693 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php @@ -0,0 +1,101 @@ + $settings + */ + private function makeBuilder(array $settings): MessagePayloadBuilder + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, Mockery::type('string'), Mockery::any()) + ->andReturnUsing(fn ($id, $key, $default) => $settings[$key] ?? $default); + + return new MessagePayloadBuilder($mock); + } + + public function test_sms_payload_구조(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + + $this->assertSame('acct01', $payload['account']); + $this->assertSame('sms', $payload['type']); + $this->assertSame('07012345678', $payload['from']); + $this->assertSame('01011112222', $payload['to']); + $this->assertSame('ref123', $payload['refkey']); + $this->assertSame('테스트 본문', $payload['content']['sms']['message']); + } + + public function test_lms_payload는_subject를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + + $this->assertSame('lms', $payload['type']); + $this->assertSame('제목', $payload['content']['lms']['subject']); + $this->assertSame('긴 본문', $payload['content']['lms']['message']); + } + + public function test_lms_subject_null이면_생략된다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '본문', 'ref789', null); + + $this->assertArrayNotHasKey('subject', $payload['content']['lms']); + } + + public function test_알림톡_payload는_senderkey와_templatecode를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + + $this->assertSame('at', $payload['type']); + $at = $payload['content']['at']; + $this->assertSame('senderkey40', $at['senderkey']); + $this->assertSame('TW_1234', $at['templatecode']); + $this->assertSame('알림톡 본문', $at['message']); + // extra(버튼) 병합 확인 + $this->assertSame('확인', $at['button'][0]['name']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php new file mode 100644 index 00000000..32832a0e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php @@ -0,0 +1,63 @@ +resolver = new SmsTypeResolver; + } + + public function test_영문_짧은_본문은_sms(): void + { + // 영문 45바이트 → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 45))); + } + + public function test_한글은_euckr_2바이트로_계산된다(): void + { + // 한글 45자 = 90byte → 경계 안(SMS) + $this->assertSame(90, $this->resolver->byteLength(str_repeat('가', 45))); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('가', 45))); + + // 한글 46자 = 92byte → SMS 경계 초과 → LMS + $this->assertSame(92, $this->resolver->byteLength(str_repeat('가', 46))); + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('가', 46))); + } + + public function test_영문_90바이트_경계와_초과(): void + { + // 정확히 90byte → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 90))); + // 91byte → LMS + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('a', 91))); + } + + public function test_이모지는_제거되어_byte에_포함되지_않는다(): void + { + // 영문 10 + 이모지 → 이모지 제거 후 10byte 만 계산(EUC-KR 미표현 제거) + $this->assertSame(10, $this->resolver->byteLength('aaaaaaaaaa😀')); + // 이모지만 있는 경우 0byte → SMS + $this->assertSame(0, $this->resolver->byteLength('😀😀')); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve('😀')); + } + + public function test_lms_한도_초과_판정(): void + { + // 영문 2000byte → 한도 이내(false) + $this->assertFalse($this->resolver->exceedsLmsLimit(str_repeat('a', 2000))); + // 2001byte → 초과(true) + $this->assertTrue($this->resolver->exceedsLmsLimit(str_repeat('a', 2001))); + } +}