feat(message_bizppurio): 비즈뿌리오 발송 엔진 API 클라이언트
발송 시스템(api.bizppurio.com)과 카카오 관리 시스템(kapi.ppurio.com)의 API 클라이언트를 인증 방식(토큰 vs bizId+apiKey)에 따라 분리 구현했다. 토큰 캐시(23h)·재발급, SMS/LMS EUC-KR byte 자동 판별, ShouldQueue 발송 Job(429·일시오류 재시도, 영구실패 즉시)을 포함한다. 실제 발송 없이 Http::fake 단위테스트 30건으로 검증했다.
This commit is contained in:
1 parent
b48ac91fec
commit
6549170bda
17 files changed
+1574
-13
No files matched your search
@@ -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.',
|
||||
],
|
||||
];
|
||||
@@ -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 요청에 실패했습니다.',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 API 호출 실패 예외.
|
||||
*
|
||||
* 발송(api.bizppurio.com)·카카오 관리(kapi.ppurio.com) 두 시스템의 API 호출
|
||||
* 단계에서 발생하는 실패(HTTP 오류, 응답 code 비정상, 응답 파싱 실패 등)를 단일
|
||||
* 도메인 예외로 통합한다. 베이스 \Exception 직접 throw 대신 본 클래스를 사용해
|
||||
* 외부 소비자가 비즈뿌리오 도메인 오류만 선택적으로 catch 할 수 있도록 한다.
|
||||
*
|
||||
* 비즈뿌리오 응답 결과코드(예: 3002 토큰무효, 3006 계정오류)를 함께 보존하여
|
||||
* SendMessageJob 의 재시도/영구실패 판정(ResultCodeResolver, Phase 4)이 코드로
|
||||
* 분기할 수 있게 한다. HTTP 전송 자체가 실패한 경우 결과코드는 null 이다.
|
||||
*/
|
||||
class BizppurioApiException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param string $message 예외 메시지
|
||||
* @param string|null $resultCode 비즈뿌리오 응답 결과코드(있으면). 전송 실패 시 null
|
||||
* @param int|null $httpStatus HTTP 상태 코드(있으면)
|
||||
* @param int $code 예외 코드(기본 0)
|
||||
* @param Throwable|null $previous 원인 예외
|
||||
*/
|
||||
public function __construct(
|
||||
string $message = '',
|
||||
private readonly ?string $resultCode = null,
|
||||
private readonly ?int $httpStatus = null,
|
||||
int $code = 0,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 응답 결과코드를 반환합니다.
|
||||
*
|
||||
* @return string|null 결과코드(예: "3002"), 전송 실패 시 null
|
||||
*/
|
||||
public function getResultCode(): ?string
|
||||
{
|
||||
return $this->resultCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 상태 코드를 반환합니다.
|
||||
*
|
||||
* @return int|null HTTP 상태 코드, 없으면 null
|
||||
*/
|
||||
public function getHttpStatus(): ?int
|
||||
{
|
||||
return $this->httpStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Jobs;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 메시지 발송 Job.
|
||||
*
|
||||
* MessagePayloadBuilder 가 조립한 발송 payload 를 큐에서 BizppurioApiClient 로
|
||||
* 전송한다. 서버 QUEUE_CONNECTION 을 따르며, 아래 재시도 정책을 적용한다(계획서 D10):
|
||||
*
|
||||
* - 429(Rate Limit): 예외를 던져 큐가 재시도. sync 환경은 최대 2초 대기 후 1회 재시도.
|
||||
* - 일시 오류(5003·5004·5005·9000·3011·3013): 예외를 던져 재시도.
|
||||
* - 영구 실패(2000·3001·3004·3006·3007·3009·9070·7436 등): 예외 없이 종료(재시도 안 함).
|
||||
*
|
||||
* 결과코드의 정식 분류·이력 갱신은 Phase 4(ResultCodeResolver/webhook)가 담당한다.
|
||||
* 이 Job 은 발송 수행과 재시도 판정까지의 골격을 제공한다.
|
||||
*/
|
||||
class SendMessageJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
/** 최대 재시도 횟수 (429·일시오류 대상). sync 환경 1회 재시도 포함 */
|
||||
public int $tries = 2;
|
||||
|
||||
/** 재시도 대상 일시 오류 결과코드 (부록 C-3) */
|
||||
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013'];
|
||||
|
||||
/** HTTP 429 재시도 시 sync 환경 최대 대기(초) */
|
||||
private const SYNC_RETRY_MAX_WAIT_SECONDS = 2;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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<int, class-string>
|
||||
*/
|
||||
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
|
||||
{
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 시스템(api.bizppurio.com) API 클라이언트.
|
||||
*
|
||||
* `/v3/message` 를 Bearer 토큰 인증으로 호출해 SMS/LMS/알림톡을 발송한다.
|
||||
* 완성된 payload(MessagePayloadBuilder 조립)를 받아 전송하며, 응답 결과코드가
|
||||
* 3002(토큰무효)/3005(인증정보무효)면 토큰을 재발급(BizppurioTokenService)한 뒤
|
||||
* 1회 재시도한다. environment 설정에 따라 운영/검수 도메인을 분기한다(카카오 관리
|
||||
* 시스템 kapi 와 달리 발송 시스템만 도메인 분기 대상).
|
||||
*/
|
||||
class BizppurioApiClient
|
||||
{
|
||||
/** 플러그인 식별자 (manifest 와 일치) */
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/** 운영 발송 도메인 */
|
||||
private const HOST_LIVE = 'https://api.bizppurio.com';
|
||||
|
||||
/** 검수 발송 도메인 */
|
||||
private const HOST_DEV = 'https://dev-api.bizppurio.com';
|
||||
|
||||
/** 토큰·인증 무효 결과코드 — forget 후 재발급 트리거 */
|
||||
private const AUTH_INVALID_CODES = ['3002', '3005'];
|
||||
|
||||
/** 발송 응답 성공 결과코드 */
|
||||
private const SUCCESS_CODE = '1000';
|
||||
|
||||
private const CONNECT_TIMEOUT_SECONDS = 5;
|
||||
|
||||
private const REQUEST_TIMEOUT_SECONDS = 20;
|
||||
|
||||
/**
|
||||
* @param BizppurioTokenService $tokenService 발송 토큰 발급·캐시
|
||||
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioTokenService $tokenService,
|
||||
private readonly PluginSettingsService $pluginSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 완성된 발송 payload 를 `/v3/message` 로 전송합니다.
|
||||
*
|
||||
* 토큰·인증 무효(3002/3005) 응답 시 토큰을 재발급한 뒤 1회 재시도합니다.
|
||||
*
|
||||
* @param array<string, mixed> $payload MessagePayloadBuilder 가 조립한 발송 본문
|
||||
* @return array<string, mixed> 응답 배열 (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<string, mixed> $result sendMessage 응답
|
||||
* @return bool 성공이면 true
|
||||
*/
|
||||
public function isSuccess(array $result): bool
|
||||
{
|
||||
return (string) ($result['code'] ?? '') === self::SUCCESS_CODE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 발송 요청을 수행합니다.
|
||||
*
|
||||
* @param array<string, mixed> $payload 발송 본문
|
||||
* @param string $token Bearer 액세스 토큰
|
||||
* @return array<string, mixed> 응답 배열
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 카카오 관리 시스템(kapi.ppurio.com) API 클라이언트.
|
||||
*
|
||||
* 발송 시스템(api.bizppurio.com)과 별개의 시스템으로, 알림톡 템플릿 등록·검수·조회
|
||||
* 및 발신프로필 조회를 담당한다. 토큰 인증이 아니라 매 요청 body 에 bizId + apiKey
|
||||
* 를 실어 인증하며, 도메인은 검수/운영 구분 없이 단일(kapi.ppurio.com)이다.
|
||||
*
|
||||
* 모든 요청은 POST + JSON 이며 응답은 `{code, message, data}` 봉투를 따른다.
|
||||
* 성공 결과코드는 "200" 이다. 이 클래스는 공통 POST 위임(bizId/apiKey 자동 주입)과
|
||||
* Phase 5·6 에서 소비하는 엔드포인트 메서드를 제공한다.
|
||||
*/
|
||||
class BizppurioKakaoApiClient
|
||||
{
|
||||
/** 플러그인 식별자 (manifest 와 일치) */
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/** 카카오 관리 시스템 단일 도메인 (검수/운영 구분 없음) */
|
||||
private const BASE_URL = 'https://kapi.ppurio.com';
|
||||
|
||||
/** 카카오 관리 API 성공 결과코드 */
|
||||
private const SUCCESS_CODE = '200';
|
||||
|
||||
private const CONNECT_TIMEOUT_SECONDS = 5;
|
||||
|
||||
private const REQUEST_TIMEOUT_SECONDS = 20;
|
||||
|
||||
/**
|
||||
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회(bizId=bizppurio_id, apiKey)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PluginSettingsService $pluginSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 발신프로필 목록을 조회합니다. (`/v3/kakao/profile/use`)
|
||||
*
|
||||
* @return array<string, mixed> 응답 배열 (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<string, mixed> $params 추가 조회 파라미터 (count/page/status 등)
|
||||
* @return array<string, mixed> 응답 배열 (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<string, mixed> 응답 배열 (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<string, mixed> $params 요청 파라미터 (bizId/apiKey 제외)
|
||||
* @return array<string, mixed> 응답 배열 (code/message/data)
|
||||
*
|
||||
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
|
||||
*/
|
||||
public function request(string $path, array $params = []): array
|
||||
{
|
||||
return $this->post($path, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카카오 관리 API 응답이 성공(200)인지 판정합니다.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $params 요청 파라미터 (bizId/apiKey 제외)
|
||||
* @return array<string, mixed> 응답 배열
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 시스템(api.bizppurio.com) 인증 토큰 서비스.
|
||||
*
|
||||
* `/v1/token` 을 Basic 인증(계정:암호 Base64)으로 호출해 Bearer 토큰을 발급받고,
|
||||
* 확장 도메인 캐시(CacheInterface)에 저장한다. 토큰 유효 시간은 24시간이나, 만료
|
||||
* 경계 재발급 리스크를 피하기 위해 23시간 TTL 로 캐시한다(PO 결정, 계획서 §5).
|
||||
*
|
||||
* 발송 응답이 3002(토큰무효)/3005(인증정보무효)를 반환하면 캐시를 forget 하고
|
||||
* 1회 재발급한다(BizppurioApiClient 가 재시도 트리거). CacheInterface 는
|
||||
* MessageBizppurioServiceProvider 의 $cacheServices contextual binding 으로 주입된다.
|
||||
*/
|
||||
class BizppurioTokenService
|
||||
{
|
||||
/** 플러그인 식별자 (manifest 와 일치) */
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/** 토큰 캐시 키 (확장 도메인 캐시 접두사가 자동 적용됨) */
|
||||
private const CACHE_KEY = 'bizppurio:token';
|
||||
|
||||
/** 토큰 캐시 TTL (초) — 23시간. 유효 24h 대비 만료 경계 회피 */
|
||||
private const CACHE_TTL_SECONDS = 23 * 3600;
|
||||
|
||||
/** 운영 발송 도메인 */
|
||||
private const HOST_LIVE = 'https://api.bizppurio.com';
|
||||
|
||||
/** 검수 발송 도메인 */
|
||||
private const HOST_DEV = 'https://dev-api.bizppurio.com';
|
||||
|
||||
private const CONNECT_TIMEOUT_SECONDS = 5;
|
||||
|
||||
private const REQUEST_TIMEOUT_SECONDS = 15;
|
||||
|
||||
/**
|
||||
* @param CacheInterface $cache 확장 도메인 캐시 드라이버(contextual binding 주입)
|
||||
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly PluginSettingsService $pluginSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 캐시된 토큰을 반환하거나, 없으면 새로 발급받아 캐시합니다.
|
||||
*
|
||||
* @return string Bearer 액세스 토큰
|
||||
*
|
||||
* @throws BizppurioApiException 토큰 발급 실패 시
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송(`/v3/message`) payload 조립기.
|
||||
*
|
||||
* SMS/LMS/알림톡(at) content 를 부록 C-2(매뉴얼 5.메시지발송) 구조로 조립한다.
|
||||
* 공통 필드(account/from/to/refkey/type)와 채널별 content 를 결합하며, account 는
|
||||
* bizppurio_id, from 은 sender_number, 알림톡 senderkey 는 sender_key 를 환경설정에서
|
||||
* 읽는다. refkey(우리 부여 unique 키)와 수신번호·본문은 호출측(채널 드라이버)이 전달한다.
|
||||
*
|
||||
* 대체발송(resend/recontent)·예약(sendtime) 등 선택 필드는 1차 범위에서 채널
|
||||
* 드라이버(Phase 3·6)가 필요 시 반환 배열에 병합한다. 이 빌더는 필수 골격만 만든다.
|
||||
*/
|
||||
class MessagePayloadBuilder
|
||||
{
|
||||
/** 플러그인 식별자 (manifest 와 일치) */
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/**
|
||||
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly PluginSettingsService $pluginSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* SMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 90byte)
|
||||
*
|
||||
* @param string $to 수신 전화번호
|
||||
* @param string $message 본문
|
||||
* @param string $refkey 우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)
|
||||
* @return array<string, mixed> 발송 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<string, mixed> 발송 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<string, mixed> $extra button/quickreply/title 등 유형별 선택 필드
|
||||
* @return array<string, mixed> 발송 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<string, mixed> $content 채널별 content 배열
|
||||
* @return array<string, mixed> 발송 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', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
|
||||
/**
|
||||
* 문자 발송 유형(SMS/LMS) 판별기.
|
||||
*
|
||||
* 비즈뿌리오는 문자 본문을 EUC-KR 기준 byte 로 계산해 SMS(≤90byte)/LMS(≤2000byte)를
|
||||
* 구분한다. 본문에는 변수 치환이 끝난 최종 문자열을 넘겨야 하며(계획서 D4), 여기서
|
||||
* byte 를 재서 유형을 확정한다. SMS 경계를 넘으면 자동으로 LMS 가 된다.
|
||||
*
|
||||
* 이모지 등 EUC-KR 로 표현 불가능한 문자는 변환 시 물음표(?)로 치환되어 byte 가
|
||||
* 왜곡되므로, 판별 전 제거한다(발송 본문 자체의 이모지 처리는 발송측이 담당).
|
||||
*/
|
||||
class SmsTypeResolver
|
||||
{
|
||||
/** SMS 최대 byte (EUC-KR 기준) */
|
||||
public const SMS_MAX_BYTES = 90;
|
||||
|
||||
/** LMS 최대 byte (EUC-KR 기준) */
|
||||
public const LMS_MAX_BYTES = 2000;
|
||||
|
||||
/**
|
||||
* 본문의 EUC-KR byte 길이로 SMS/LMS 유형을 판별합니다.
|
||||
*
|
||||
* @param string $message 변수 치환이 끝난 최종 본문
|
||||
* @return DispatchChannel SMS 또는 LMS
|
||||
*/
|
||||
public function resolve(string $message): DispatchChannel
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Jobs;
|
||||
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* SendMessageJob — 성공/일시오류 재시도/영구실패 즉시 판정 검증.
|
||||
*/
|
||||
class SendMessageJobTest extends PluginTestCase
|
||||
{
|
||||
private array $payload = [
|
||||
'account' => 'acct', 'type' => 'sms', 'from' => '070', 'to' => '010',
|
||||
'refkey' => 'ref1', 'content' => ['sms' => ['message' => 'hi']],
|
||||
];
|
||||
|
||||
/**
|
||||
* sendMessage 응답을 반환하는 ApiClient mock 을 만듭니다.
|
||||
*
|
||||
* @param array<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioApiClient — /v3/message 발송·결과코드·토큰 재발급 재시도 검증.
|
||||
*/
|
||||
class BizppurioApiClientTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
private array $payload = [
|
||||
'account' => '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();
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioKakaoApiClient — kapi 단일 도메인, bizId+apiKey body 인증 검증.
|
||||
*/
|
||||
class BizppurioKakaoApiClientTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/**
|
||||
* @param array<string, string> $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();
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<?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\Exceptions\BizppurioApiException;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioTokenService — 토큰 발급·캐시 재사용·재발급 검증.
|
||||
*/
|
||||
class BizppurioTokenServiceTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/**
|
||||
* 메모리 배열로 remember/forget 을 재현하는 CacheInterface mock.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, string> $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();
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use App\Services\PluginSettingsService;
|
||||
use Mockery;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* MessagePayloadBuilder — SMS/LMS/알림톡 payload 조립(부록 C-2 구조) 검증.
|
||||
*/
|
||||
class MessagePayloadBuilderTest extends PluginTestCase
|
||||
{
|
||||
private const IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/**
|
||||
* 환경설정 값을 반환하는 PluginSettingsService mock 을 만들어 빌더를 생성합니다.
|
||||
*
|
||||
* @param array<string, string> $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();
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* SmsTypeResolver — EUC-KR byte 판별(SMS≤90/LMS≤2000) 검증.
|
||||
*/
|
||||
class SmsTypeResolverTest extends PluginTestCase
|
||||
{
|
||||
private SmsTypeResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user