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:
chym1217 committed 2026-07-28 12:36:18 +09:00
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,
]);
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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)));
}
}