From a0943987fbfe147247161cca2c5d7bacde0693df Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 08:08:24 +0900 Subject: [PATCH 1/6] Add documentation files for SOLAPI PHP SDK - Introduced AGENTS.md for detailed SDK structure, usage, and conventions. - Added CLAUDE.md to provide guidance for code usage and architecture overview. --- AGENTS.md | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 65 ++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0a5b0a9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,108 @@ +# SOLAPI PHP SDK + +**Generated:** 2026-01-21 +**Commit:** b68825d +**Branch:** master + +## OVERVIEW + +PHP SDK for SOLAPI messaging API (SMS, LMS, MMS, Kakao Alimtalk, Voice, Fax) targeting Korean telecom. Zero external dependencies, PHP 7.1+. + +## STRUCTURE + +``` +solapi-php/ +├── src/ +│ ├── Services/ # Entry point (SolapiMessageService) +│ ├── Libraries/ # HTTP client, auth, utilities +│ ├── Models/ +│ │ ├── Request/ # API request DTOs (7 files) +│ │ ├── Response/ # API response DTOs (17 files) +│ │ ├── Kakao/ # Kakao message options +│ │ ├── Voice/ # Voice message options +│ │ └── Fax/ # Fax message options +│ └── Exceptions/ # Custom exceptions (4 files) +├── composer.json # PSR-4: Nurigo\Solapi\ → src/ +└── README.md +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Send messages | `Services/SolapiMessageService.php` | Main entry point, all public methods | +| Build message | `Models/Message.php` | Fluent builder, extends BaseMessage | +| HTTP requests | `Libraries/Fetcher.php` | Singleton, CURL-based | +| Auth header | `Libraries/Authenticator.php` | HMAC-SHA256, static method | +| Kakao options | `Models/Kakao/KakaoOption.php` | pfId, templateId, buttons, bms | +| Voice options | `Models/Voice/VoiceOption.php` | voiceType, headerMessage, tailMessage | +| Error handling | `Exceptions/` | BaseException, CurlException, MessageNotReceivedException | +| Request params | `Models/Request/` | SendRequest, GetMessagesRequest, etc. | +| Response parsing | `Models/Response/` | SendResponse, GroupMessageResponse, etc. | + +## CODE MAP + +**Entry Point:** +```php +$service = new SolapiMessageService($apiKey, $apiSecret); +$response = $service->send($message); +``` + +**Call Flow:** +``` +SolapiMessageService → Fetcher (singleton) → Authenticator (static) + → CURL → api.solapi.com + → Response DTOs +``` + +**Key Classes:** +| Class | Type | Role | +|-------|------|------| +| `SolapiMessageService` | Service | Primary API (send, uploadFile, getMessages, getGroups, getBalance) | +| `Message` | Model | Message builder with fluent setters | +| `Fetcher` | Library | HTTP client singleton, handles all API requests | +| `Authenticator` | Library | Generates HMAC-SHA256 auth headers | +| `NullEliminator` | Library | Removes null values before JSON serialization | + +## CONVENTIONS + +**Namespace:** `Nurigo\Solapi\*` (PSR-4 from `src/`) + +**Patterns:** +- Fluent builder: `$msg->setTo("...")->setFrom("...")->setText("...")` +- Singleton: `Fetcher::getInstance($key, $secret)` +- Public properties with getters/setters on models +- Korean PHPDoc comments (domain-specific) + +**Type Safety:** +- Full type hints on method params/returns +- PHPDoc `@var`, `@param`, `@return`, `@throws` annotations + +## ANTI-PATTERNS + +- **Avoid catch-all nulls:** Many get* methods return `null` on any exception — check response validity +- **Singleton state:** Fetcher singleton retains credentials — don't mix different API keys in same process +- **No interfaces:** Service/Fetcher have no contracts — mocking requires concrete class extension +- **SSL verification disabled:** `CURLOPT_SSL_VERIFYPEER = false` in Fetcher + +## UNIQUE STYLES + +- **Korean comments:** PHPDoc descriptions in Korean (수신번호, 발신번호, 메시지 내용) +- **Default country:** `"82"` (Korea) hardcoded in BaseMessage +- **Timezone:** `Asia/Seoul` set in Authenticator + +## COMMANDS + +```bash +# Install +composer require solapi/sdk + +# No local tests — see solapi-php-examples repo +``` + +## NOTES + +- **Examples:** External repo at `github.com/solapi/solapi-php-examples` +- **API docs:** `developers.solapi.com` +- **PHP requirement:** 7.1+ (ext-curl, ext-json required) +- **TODO in README:** Missing documentation link (line 19) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9c8ca27 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +SOLAPI PHP SDK - A zero-dependency messaging SDK for Korean telecommunications (SMS, LMS, MMS, Kakao Alimtalk, Voice, Fax). Version 5.0.6, requires PHP 7.1+ with curl and json extensions. + +## Commands + +```bash +# Install dependencies (none required, autoloader only) +composer install + +# There are no local tests - see https://github.com/solapi/solapi-php-examples for usage examples +``` + +## Architecture + +**Entry Point:** `SolapiMessageService` in `src/Services/` - all public API methods + +**Call Flow:** +``` +SolapiMessageService → Fetcher (singleton) → Authenticator (static) → CURL → api.solapi.com +``` + +**Key Classes:** +- `SolapiMessageService` - Primary API: send(), uploadFile(), getMessages(), getGroups(), getBalance() +- `Message` (`Models/Message.php`) - Fluent builder for message construction +- `Fetcher` (`Libraries/Fetcher.php`) - Singleton HTTP client +- `Authenticator` (`Libraries/Authenticator.php`) - HMAC-SHA256 auth header generation + +**Models Structure:** +- `Models/Request/` - 7 request DTOs (SendRequest, GetMessagesRequest, etc.) +- `Models/Response/` - 17 response DTOs (SendResponse, GroupMessageResponse, etc.) +- `Models/Kakao/` - Kakao message options (pfId, templateId, buttons) +- `Models/Voice/` - Voice message options +- `Models/Fax/` - Fax message options + +## Code Conventions + +**Namespace:** `Nurigo\Solapi\*` (PSR-4 autoload from `src/`) + +**Patterns:** +- Fluent builder: `$msg->setTo("...")->setFrom("...")->setText("...")` +- Singleton: `Fetcher::getInstance($apiKey, $apiSecret)` +- Public properties with getters/setters on all model classes +- Full type hints on method params/returns with PHPDoc annotations + +**Language Notes:** +- PHPDoc comments are in Korean (수신번호, 발신번호, 메시지 내용) +- Default country code is "82" (Korea) in BaseMessage +- Timezone hardcoded to Asia/Seoul in Authenticator + +## Important Behaviors + +- **Singleton State:** Fetcher singleton retains credentials - don't mix different API keys in the same process +- **Null Returns:** Many get* methods return `null` on any exception instead of throwing - always check response validity +- **No Interfaces:** Service/Fetcher lack contracts - mocking requires concrete class extension +- **SSL Verification:** Disabled in Fetcher (`CURLOPT_SSL_VERIFYPEER = false`) + +## External Resources + +- API Documentation: https://developers.solapi.com +- Examples Repository: https://github.com/solapi/solapi-php-examples From e35469d56c910a1c982497f8dcfaf2c07a42965b Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:06:01 +0900 Subject: [PATCH 2/6] Add Tidy First principles to AGENTS.md and CLAUDE.md - Introduced "Tidy First" principles by Kent Beck to enhance code quality and maintainability. - Added guidelines for separating structural and behavioral changes, and practical techniques for tidying code. - Emphasized the importance of tidying before feature additions and bug fixes. --- AGENTS.md | 5 +++++ CLAUDE.md | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 0a5b0a9..f4a2a85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,11 @@ SolapiMessageService → Fetcher (singleton) → Authenticator (static) - Full type hints on method params/returns - PHPDoc `@var`, `@param`, `@return`, `@throws` annotations +**Tidy First (Kent Beck):** +- Separate structural and behavioral changes into distinct commits +- Tidy related code before making feature changes +- Guard clauses, helper variables/functions, code proximity, symmetry normalization, delete unused code + ## ANTI-PATTERNS - **Avoid catch-all nulls:** Many get* methods return `null` on any exception — check response validity diff --git a/CLAUDE.md b/CLAUDE.md index 9c8ca27..f016845 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,27 @@ SolapiMessageService → Fetcher (singleton) → Authenticator (static) → CURL - Default country code is "82" (Korea) in BaseMessage - Timezone hardcoded to Asia/Seoul in Authenticator +## Tidy First Principles + +Follow Kent Beck's "Tidy First" principles when making code changes: + +**Core Principles:** +- **Separate Structure from Behavior**: Separate structural changes (tidying) and behavioral changes (features) into distinct commits +- **Tidy First**: Tidy related code before making feature changes to improve changeability +- **Small Steps**: Keep tidying work completable within minutes to hours + +**Practical Techniques:** +- Use guard clauses for early returns to eliminate nested if statements +- Use helper variables/functions to clarify complex expressions +- Keep related code physically close together +- Express identical logic in identical ways (normalize symmetry) +- Delete unused code immediately + +**When to Apply:** +- Before adding new features, tidy the affected area +- Before fixing bugs, clarify related code +- During code review, identify tidying opportunities + ## Important Behaviors - **Singleton State:** Fetcher singleton retains credentials - don't mix different API keys in the same process From 1267e0b0bd75e0f689ea769a7956aa9519c34352 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:33:50 +0900 Subject: [PATCH 3/6] Upgrade SOLAPI PHP SDK to version 5.1.0 - Transitioned from raw CURL to PSR-18 compatible HTTP client for improved security and flexibility. - Added new dependencies: `psr/http-client`, `psr/http-message`, and `nyholm/psr7`. - Introduced `HttpClient` class for handling HTTP requests and responses. - Deprecated `CurlException` in favor of `HttpException` for better error handling. - Added documentation on migration rationale from CURL to PSR-18. --- composer.json | 8 +- composer.lock | 246 ++++++++++++++++++++++++++++++- docs/curl-migration-rationale.md | 116 +++++++++++++++ src/Exceptions/CurlException.php | 9 +- src/Exceptions/HttpException.php | 56 +++++++ src/Libraries/Fetcher.php | 116 ++++++++------- src/Libraries/HttpClient.php | 130 ++++++++++++++++ 7 files changed, 615 insertions(+), 66 deletions(-) create mode 100644 docs/curl-migration-rationale.md create mode 100644 src/Exceptions/HttpException.php create mode 100644 src/Libraries/HttpClient.php diff --git a/composer.json b/composer.json index c7831f9..0a2c48a 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "solapi/sdk", "description": "SOLAPI SDK for PHP", - "version": "5.0.6", + "version": "5.1.0", "type": "library", "license": "MIT", "autoload": { @@ -41,7 +41,9 @@ "homepage": "https://solapi.com", "require": { "php": ">=7.1", - "ext-curl": "*", - "ext-json": "*" + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "nyholm/psr7": "^1.5" } } diff --git a/composer.lock b/composer.lock index 4bc6943..b8df1ef 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,247 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e2397fc992c265ed0e64aa5781390ddb", - "packages": [], + "content-hash": "444b80d88c2d5893e6c825e22cef8d11", + "packages": [ + { + "name": "nyholm/psr7", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/Nyholm/psr7.git", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0", + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "php-http/message-factory": "^1.0", + "php-http/psr7-integration-tests": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.4", + "symfony/error-handler": "^4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8-dev" + } + }, + "autoload": { + "psr-4": { + "Nyholm\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com" + }, + { + "name": "Martijn van der Ven", + "email": "martijn@vanderven.se" + } + ], + "description": "A fast PHP7 implementation of PSR-7", + "homepage": "https://tnyholm.se", + "keywords": [ + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/Nyholm/psr7/issues", + "source": "https://github.com/Nyholm/psr7/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/Zegnat", + "type": "github" + }, + { + "url": "https://github.com/nyholm", + "type": "github" + } + ], + "time": "2024-09-09T07:06:30+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + } + ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", @@ -14,9 +253,8 @@ "prefer-lowest": false, "platform": { "php": ">=7.1", - "ext-curl": "*", "ext-json": "*" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/docs/curl-migration-rationale.md b/docs/curl-migration-rationale.md new file mode 100644 index 0000000..60e776d --- /dev/null +++ b/docs/curl-migration-rationale.md @@ -0,0 +1,116 @@ +# CURL에서 PSR-18 HTTP 클라이언트로의 마이그레이션 + +## 개요 + +SOLAPI PHP SDK v5.1.0부터 HTTP 클라이언트가 raw CURL에서 PSR-18 호환 구현으로 변경되었습니다. + +## 기존 CURL 구현의 문제점 + +### 1. 보안 취약점: SSL 인증서 검증 비활성화 + +```php +curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); +``` + +- MITM 공격에 취약 +- PCI-DSS, SOC2 등 보안 규정 위반 가능성 + +### 2. 타임아웃 설정 부재 + +- `CURLOPT_TIMEOUT`, `CURLOPT_CONNECTTIMEOUT` 미설정 +- 서버 무응답 시 무한 대기 + +### 3. ext-curl 필수 의존성 + +- 공유 호스팅, Docker, Serverless 환경에서 문제 발생 가능 +- 환경별 호환성 이슈 + +### 4. Guzzle 버전 충돌 + +- Guzzle 6.x 사용 프로젝트와 충돌 +- Guzzle 7.x만 지원 시 기존 사용자 업그레이드 강제 + +--- + +## 새로운 PSR-18 기반 구현 + +### 장점 + +| 항목 | 설명 | +|------|------| +| **Guzzle 독립적** | Guzzle 6.x, 7.x 모두와 충돌 없음 | +| **PSR-18 호환** | 표준 인터페이스로 다른 HTTP 클라이언트 주입 가능 | +| **순수 PHP** | ext-curl 불필요, PHP streams 사용 | +| **보안 강화** | SSL 검증 기본 활성화 | +| **타임아웃 설정** | 30초 기본 타임아웃 | + +### 의존성 + +```json +{ + "require": { + "php": ">=7.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0", + "nyholm/psr7": "^1.5" + } +} +``` + +- `psr/http-client`: PSR-18 HTTP Client 인터페이스 +- `psr/http-message`: PSR-7 HTTP Message 인터페이스 +- `nyholm/psr7`: 경량 PSR-7 구현 (126M+ 다운로드) + +--- + +## 커스텀 HTTP 클라이언트 사용 + +Guzzle이나 다른 PSR-18 호환 클라이언트를 사용하려면: + +```php +use GuzzleHttp\Client as GuzzleClient; +use Nurigo\Solapi\Libraries\Fetcher; + +// Guzzle 클라이언트 생성 +$guzzle = new GuzzleClient([ + 'timeout' => 60, + 'verify' => true, +]); + +// Fetcher에 주입 +$fetcher = new Fetcher('API_KEY', 'API_SECRET', $guzzle); +``` + +### 지원되는 PSR-18 클라이언트 + +- Guzzle 7.x (`guzzlehttp/guzzle`) +- Symfony HttpClient (`symfony/http-client`) +- 기타 PSR-18 호환 클라이언트 + +--- + +## 하위 호환성 + +### 변경 없음 + +- `SolapiMessageService`의 모든 public 메서드 +- `Message` fluent builder +- 예외 처리 (`catch (CurlException $e)`) + +### 변경됨 + +| 항목 | 이전 | 이후 | +|------|------|------| +| PHP 버전 | >= 7.1 | >= 7.1 (유지) | +| HTTP 클라이언트 | raw CURL | PSR-18 (PHP streams) | +| ext-curl | 필수 | 불필요 | +| SSL 검증 | 비활성화 | 활성화 | +| 타임아웃 | 없음 | 30초 | + +--- + +## 참고 자료 + +- [PSR-18: HTTP Client](https://www.php-fig.org/psr/psr-18/) +- [PSR-7: HTTP Message Interface](https://www.php-fig.org/psr/psr-7/) +- [nyholm/psr7](https://github.com/Nyholm/psr7) diff --git a/src/Exceptions/CurlException.php b/src/Exceptions/CurlException.php index 827c047..2c6a0a3 100644 --- a/src/Exceptions/CurlException.php +++ b/src/Exceptions/CurlException.php @@ -2,9 +2,10 @@ namespace Nurigo\Solapi\Exceptions; -use Exception; - -class CurlException extends Exception +/** + * @deprecated HttpException을 사용하세요. 하위 호환성을 위해 유지됩니다. + */ +class CurlException extends HttpException { -} \ No newline at end of file +} diff --git a/src/Exceptions/HttpException.php b/src/Exceptions/HttpException.php new file mode 100644 index 0000000..150ea80 --- /dev/null +++ b/src/Exceptions/HttpException.php @@ -0,0 +1,56 @@ +statusCode = $statusCode; + $this->responseBody = $responseBody; + } + + /** + * @return int|null + */ + public function getStatusCode(): ?int + { + return $this->statusCode; + } + + /** + * @return string|null + */ + public function getResponseBody(): ?string + { + return $this->responseBody; + } +} diff --git a/src/Libraries/Fetcher.php b/src/Libraries/Fetcher.php index dbe8850..f7859bb 100644 --- a/src/Libraries/Fetcher.php +++ b/src/Libraries/Fetcher.php @@ -3,45 +3,44 @@ namespace Nurigo\Solapi\Libraries; use Exception; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Client\ClientExceptionInterface; +use Nyholm\Psr7\Request; +use Nyholm\Psr7\Uri; use Nurigo\Solapi\Exceptions\BaseException; use Nurigo\Solapi\Exceptions\CurlException; use Nurigo\Solapi\Exceptions\UnknownException; use Nurigo\Solapi\Models\Response\ErrorResponse; -/** - * @template T, R - */ class Fetcher { - /** - * @var Fetcher - */ private static $singleton; protected $apiKey = ''; protected $apiSecretKey = ''; + protected $httpClient; const API_URL = "https://api.solapi.com"; + const DEFAULT_TIMEOUT = 30.0; + const DEFAULT_CONNECT_TIMEOUT = 10.0; - /** - * @param string $apiKey - * @param string $apiSecretKey - * @return Fetcher - */ public static function getInstance(string $apiKey, string $apiSecretKey): Fetcher { - if (!isset(Fetcher::$singleton)) Fetcher::$singleton = new Fetcher($apiKey, $apiSecretKey); + if (!isset(Fetcher::$singleton)) { + Fetcher::$singleton = new Fetcher($apiKey, $apiSecretKey); + } return Fetcher::$singleton; } - /** - * @param string $apiKey - * @param string $apiSecretKey - */ - public function __construct(string $apiKey, string $apiSecretKey) + public function __construct(string $apiKey, string $apiSecretKey, ?ClientInterface $httpClient = null) { $this->apiKey = $apiKey; $this->apiSecretKey = $apiSecretKey; + $this->httpClient = $httpClient ?? new HttpClient([ + 'timeout' => self::DEFAULT_TIMEOUT, + 'connect_timeout' => self::DEFAULT_CONNECT_TIMEOUT, + 'verify' => true, + ]); } public function __destruct() @@ -54,50 +53,57 @@ public function __destruct() * @param string $method * @param string $uri * @param mixed $data - * @throws Exception|CurlException|BaseException|UnknownException CURL 관련된 Exception + * @return mixed + * @throws Exception|CurlException|BaseException|UnknownException */ public function request(string $method, string $uri, $data = false) { $authHeaderInfo = Authenticator::getAuthorizationHeaderInfo($this->apiKey, $this->apiSecretKey); + $headerParts = explode(': ', $authHeaderInfo, 2); + $authHeaderValue = $headerParts[1] ?? $authHeaderInfo; + $url = self::API_URL . $uri; + $body = ''; + $headers = [ + 'Authorization' => $authHeaderValue, + 'Content-Type' => 'application/json', + ]; - $curl = curl_init(); - curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method); - switch ($method) { - case "POST": - case "PUT": - case "DELETE": - if ($data) { - $data = NullEliminator::array_null_eliminate((array)$data); - $data = json_encode($data); - curl_setopt($curl, CURLOPT_POSTFIELDS, $data); - } - break; - case "GET": - if ($data) $url = sprintf("%s?%s", $url, http_build_query($data)); - break; - } - $httpHeaders = array($authHeaderInfo, "Content-Type: application/json"); - curl_setopt($curl, CURLOPT_HTTPHEADER, $httpHeaders); - curl_setopt($curl, CURLOPT_URL, $url); - curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); - curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); - $result = curl_exec($curl); - $jsonResult = json_decode($result); - - if (curl_errno($curl)) { - throw new CurlException(curl_error($curl)); - } + try { + switch ($method) { + case "POST": + case "PUT": + case "DELETE": + if ($data) { + $data = NullEliminator::array_null_eliminate((array)$data); + $body = json_encode($data); + } + break; + case "GET": + if ($data) { + $url .= '?' . http_build_query($data); + } + break; + } - $httpStatusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); - if ($httpStatusCode >= 400 && $httpStatusCode <= 500) { - $errorResponse = new ErrorResponse($jsonResult); - throw new BaseException($errorResponse->errorMessage, $errorResponse->errorCode); - } else if ($httpStatusCode != 200) { - throw new UnknownException("Unknown Http Error Occurred", $result); - } - curl_close($curl); + $request = new Request($method, new Uri($url), $headers, $body); + $response = $this->httpClient->sendRequest($request); + + $httpStatusCode = $response->getStatusCode(); + $responseBody = (string) $response->getBody(); + $jsonResult = json_decode($responseBody); - return $jsonResult; + if ($httpStatusCode >= 400 && $httpStatusCode <= 500) { + $errorResponse = new ErrorResponse($jsonResult); + throw new BaseException($errorResponse->errorMessage, $errorResponse->errorCode); + } else if ($httpStatusCode != 200) { + throw new UnknownException("Unknown Http Error Occurred", $responseBody); + } + + return $jsonResult; + + } catch (ClientExceptionInterface $e) { + throw new CurlException($e->getMessage(), null, null, $e); + } } -} \ No newline at end of file +} diff --git a/src/Libraries/HttpClient.php b/src/Libraries/HttpClient.php new file mode 100644 index 0000000..2ade483 --- /dev/null +++ b/src/Libraries/HttpClient.php @@ -0,0 +1,130 @@ +timeout = $options['timeout'] ?? 30.0; + $this->connectTimeout = $options['connect_timeout'] ?? 10.0; + $this->verifySsl = $options['verify'] ?? true; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $method = $request->getMethod(); + $url = (string) $request->getUri(); + $headers = $request->getHeaders(); + $body = (string) $request->getBody(); + + $headerLines = []; + foreach ($headers as $name => $values) { + foreach ($values as $value) { + $headerLines[] = "$name: $value"; + } + } + + $contextOptions = [ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headerLines), + 'timeout' => $this->timeout, + 'ignore_errors' => true, + ], + ]; + + if ($body !== '') { + $contextOptions['http']['content'] = $body; + } + + if (!$this->verifySsl) { + $contextOptions['ssl'] = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + ]; + } else { + $contextOptions['ssl'] = [ + 'verify_peer' => true, + 'verify_peer_name' => true, + ]; + } + + $context = stream_context_create($contextOptions); + + $responseBody = @file_get_contents($url, false, $context); + + if ($responseBody === false) { + $error = error_get_last(); + throw new CurlException( + $error['message'] ?? 'HTTP request failed: ' . $url, + null, + null, + null + ); + } + + $statusCode = $this->parseStatusCode($http_response_header ?? []); + $reasonPhrase = $this->parseReasonPhrase($http_response_header ?? []); + $responseHeaders = $this->parseHeaders($http_response_header ?? []); + + return new Response($statusCode, $responseHeaders, $responseBody, '1.1', $reasonPhrase); + } + + protected function parseStatusCode(array $headers): int + { + if (empty($headers)) { + return 0; + } + + foreach ($headers as $header) { + if (preg_match('/^HTTP\/[\d.]+\s+(\d+)/', $header, $matches)) { + return (int) $matches[1]; + } + } + + return 0; + } + + protected function parseReasonPhrase(array $headers): string + { + if (empty($headers)) { + return ''; + } + + foreach ($headers as $header) { + if (preg_match('/^HTTP\/[\d.]+\s+\d+\s+(.*)$/', $header, $matches)) { + return trim($matches[1]); + } + } + + return ''; + } + + protected function parseHeaders(array $rawHeaders): array + { + $headers = []; + foreach ($rawHeaders as $header) { + if (strpos($header, ':') !== false) { + list($name, $value) = explode(':', $header, 2); + $name = trim($name); + $value = trim($value); + if (!isset($headers[$name])) { + $headers[$name] = []; + } + $headers[$name][] = $value; + } + } + return $headers; + } +} From 87410bb8d4a91e8e0044a3a9846a45ca35218f10 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 11:34:30 +0900 Subject: [PATCH 4/6] Remove documentation on CURL migration rationale from the SOLAPI PHP SDK. This file outlined the transition from raw CURL to a PSR-18 compatible HTTP client, detailing the benefits and changes associated with the migration. --- docs/curl-migration-rationale.md | 116 ------------------------------- 1 file changed, 116 deletions(-) delete mode 100644 docs/curl-migration-rationale.md diff --git a/docs/curl-migration-rationale.md b/docs/curl-migration-rationale.md deleted file mode 100644 index 60e776d..0000000 --- a/docs/curl-migration-rationale.md +++ /dev/null @@ -1,116 +0,0 @@ -# CURL에서 PSR-18 HTTP 클라이언트로의 마이그레이션 - -## 개요 - -SOLAPI PHP SDK v5.1.0부터 HTTP 클라이언트가 raw CURL에서 PSR-18 호환 구현으로 변경되었습니다. - -## 기존 CURL 구현의 문제점 - -### 1. 보안 취약점: SSL 인증서 검증 비활성화 - -```php -curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false); -``` - -- MITM 공격에 취약 -- PCI-DSS, SOC2 등 보안 규정 위반 가능성 - -### 2. 타임아웃 설정 부재 - -- `CURLOPT_TIMEOUT`, `CURLOPT_CONNECTTIMEOUT` 미설정 -- 서버 무응답 시 무한 대기 - -### 3. ext-curl 필수 의존성 - -- 공유 호스팅, Docker, Serverless 환경에서 문제 발생 가능 -- 환경별 호환성 이슈 - -### 4. Guzzle 버전 충돌 - -- Guzzle 6.x 사용 프로젝트와 충돌 -- Guzzle 7.x만 지원 시 기존 사용자 업그레이드 강제 - ---- - -## 새로운 PSR-18 기반 구현 - -### 장점 - -| 항목 | 설명 | -|------|------| -| **Guzzle 독립적** | Guzzle 6.x, 7.x 모두와 충돌 없음 | -| **PSR-18 호환** | 표준 인터페이스로 다른 HTTP 클라이언트 주입 가능 | -| **순수 PHP** | ext-curl 불필요, PHP streams 사용 | -| **보안 강화** | SSL 검증 기본 활성화 | -| **타임아웃 설정** | 30초 기본 타임아웃 | - -### 의존성 - -```json -{ - "require": { - "php": ">=7.1", - "psr/http-client": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "nyholm/psr7": "^1.5" - } -} -``` - -- `psr/http-client`: PSR-18 HTTP Client 인터페이스 -- `psr/http-message`: PSR-7 HTTP Message 인터페이스 -- `nyholm/psr7`: 경량 PSR-7 구현 (126M+ 다운로드) - ---- - -## 커스텀 HTTP 클라이언트 사용 - -Guzzle이나 다른 PSR-18 호환 클라이언트를 사용하려면: - -```php -use GuzzleHttp\Client as GuzzleClient; -use Nurigo\Solapi\Libraries\Fetcher; - -// Guzzle 클라이언트 생성 -$guzzle = new GuzzleClient([ - 'timeout' => 60, - 'verify' => true, -]); - -// Fetcher에 주입 -$fetcher = new Fetcher('API_KEY', 'API_SECRET', $guzzle); -``` - -### 지원되는 PSR-18 클라이언트 - -- Guzzle 7.x (`guzzlehttp/guzzle`) -- Symfony HttpClient (`symfony/http-client`) -- 기타 PSR-18 호환 클라이언트 - ---- - -## 하위 호환성 - -### 변경 없음 - -- `SolapiMessageService`의 모든 public 메서드 -- `Message` fluent builder -- 예외 처리 (`catch (CurlException $e)`) - -### 변경됨 - -| 항목 | 이전 | 이후 | -|------|------|------| -| PHP 버전 | >= 7.1 | >= 7.1 (유지) | -| HTTP 클라이언트 | raw CURL | PSR-18 (PHP streams) | -| ext-curl | 필수 | 불필요 | -| SSL 검증 | 비활성화 | 활성화 | -| 타임아웃 | 없음 | 30초 | - ---- - -## 참고 자료 - -- [PSR-18: HTTP Client](https://www.php-fig.org/psr/psr-18/) -- [PSR-7: HTTP Message Interface](https://www.php-fig.org/psr/psr-7/) -- [nyholm/psr7](https://github.com/Nyholm/psr7) From c8bc54aae663f74a85eaa1a148d8a65d9660bfb7 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Thu, 22 Jan 2026 15:31:19 +0900 Subject: [PATCH 5/6] Add Kakao BMS support to SOLAPI PHP SDK - Introduced new BMS message types including TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, COMMERCE, CAROUSEL_FEED, CAROUSEL_COMMERCE, and PREMIUM_VIDEO. - Added models for BMS components such as BmsButton, BmsCarousel, BmsCommerce, and BmsVideo. - Implemented validation logic for BMS message requirements. - Created example scripts for sending various BMS message types. - Updated composer.json to include PHPUnit for testing and added autoloading for development. - Enhanced .gitignore to exclude PHPUnit cache files and vendor directory. - Added unit tests for BMS models and validation logic. --- .gitignore | 6 +- composer.json | 11 + composer.lock | 1801 ++++++++++++++++- examples/send_bms_free_carousel_feed.php | 75 + examples/send_bms_free_commerce.php | 69 + examples/send_bms_free_image.php | 48 + examples/send_bms_free_premium_video.php | 60 + examples/send_bms_free_text.php | 40 + examples/send_bms_free_wide_item_list.php | 70 + phpunit.xml | 17 + src/Exceptions/BmsValidationException.php | 11 + src/Models/Kakao/Bms/BmsButton.php | 204 ++ src/Models/Kakao/Bms/BmsButtonLinkType.php | 82 + src/Models/Kakao/Bms/BmsCarousel.php | 48 + .../Kakao/Bms/BmsCarouselCommerceItem.php | 90 + src/Models/Kakao/Bms/BmsCarouselFeedItem.php | 90 + src/Models/Kakao/Bms/BmsCarouselHead.php | 104 + src/Models/Kakao/Bms/BmsCarouselTail.php | 62 + src/Models/Kakao/Bms/BmsChatBubbleType.php | 70 + src/Models/Kakao/Bms/BmsCommerce.php | 132 ++ src/Models/Kakao/Bms/BmsCoupon.php | 154 ++ src/Models/Kakao/Bms/BmsMainWideItem.php | 90 + src/Models/Kakao/Bms/BmsSubWideItem.php | 90 + src/Models/Kakao/Bms/BmsValidator.php | 111 + src/Models/Kakao/Bms/BmsVideo.php | 81 + src/Models/Kakao/KakaoBms.php | 230 ++- tests/Models/Kakao/Bms/BmsModelsTest.php | 272 +++ tests/Models/Kakao/Bms/BmsValidatorTest.php | 300 +++ 28 files changed, 4408 insertions(+), 10 deletions(-) create mode 100644 examples/send_bms_free_carousel_feed.php create mode 100644 examples/send_bms_free_commerce.php create mode 100644 examples/send_bms_free_image.php create mode 100644 examples/send_bms_free_premium_video.php create mode 100644 examples/send_bms_free_text.php create mode 100644 examples/send_bms_free_wide_item_list.php create mode 100644 phpunit.xml create mode 100644 src/Exceptions/BmsValidationException.php create mode 100644 src/Models/Kakao/Bms/BmsButton.php create mode 100644 src/Models/Kakao/Bms/BmsButtonLinkType.php create mode 100644 src/Models/Kakao/Bms/BmsCarousel.php create mode 100644 src/Models/Kakao/Bms/BmsCarouselCommerceItem.php create mode 100644 src/Models/Kakao/Bms/BmsCarouselFeedItem.php create mode 100644 src/Models/Kakao/Bms/BmsCarouselHead.php create mode 100644 src/Models/Kakao/Bms/BmsCarouselTail.php create mode 100644 src/Models/Kakao/Bms/BmsChatBubbleType.php create mode 100644 src/Models/Kakao/Bms/BmsCommerce.php create mode 100644 src/Models/Kakao/Bms/BmsCoupon.php create mode 100644 src/Models/Kakao/Bms/BmsMainWideItem.php create mode 100644 src/Models/Kakao/Bms/BmsSubWideItem.php create mode 100644 src/Models/Kakao/Bms/BmsValidator.php create mode 100644 src/Models/Kakao/Bms/BmsVideo.php create mode 100644 tests/Models/Kakao/Bms/BmsModelsTest.php create mode 100644 tests/Models/Kakao/Bms/BmsValidatorTest.php diff --git a/.gitignore b/.gitignore index 4d1087b..207cecd 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ debug/ # Composer gitignore composer.phar -/vendor/ \ No newline at end of file +/vendor/ + +# PHPUnit +.phpunit.result.cache +.phpunit.cache/ \ No newline at end of file diff --git a/composer.json b/composer.json index 0a2c48a..832cbd3 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,11 @@ "Nurigo\\Solapi\\": "src/" } }, + "autoload-dev": { + "psr-4": { + "Nurigo\\Solapi\\Tests\\": "tests/" + } + }, "repositories": [ { "type": "vcs", @@ -45,5 +50,11 @@ "psr/http-client": "^1.0", "psr/http-message": "^1.0 || ^2.0", "nyholm/psr7": "^1.5" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "scripts": { + "test": "phpunit" } } diff --git a/composer.lock b/composer.lock index b8df1ef..817f2de 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "444b80d88c2d5893e6c825e22cef8d11", + "content-hash": "b4cf8105d51db47e7f378ee1e0176a2f", "packages": [ { "name": "nyholm/psr7", @@ -245,7 +245,1804 @@ "time": "2023-04-04T09:54:51+00:00" } ], - "packages-dev": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/23da848e1a2308728fe5fdddabf4be17ff9720c7", + "reference": "23da848e1a2308728fe5fdddabf4be17ff9720c7", + "shasum": "" + }, + "require": { + "php": "^8.4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2026-01-05T06:47:08+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "9.2.32", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/85402a822d1ecf1db1096959413d35e1c37cf1a5", + "reference": "85402a822d1ecf1db1096959413d35e1c37cf1a5", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=7.3", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-text-template": "^2.0.4", + "sebastian/code-unit-reverse-lookup": "^2.0.3", + "sebastian/complexity": "^2.0.3", + "sebastian/environment": "^5.1.5", + "sebastian/lines-of-code": "^1.0.4", + "sebastian/version": "^3.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.32" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:23:01+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "3.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2021-12-02T12:48:52+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:58:55+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T05:33:50+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "5.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:16:10+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "9.6.31", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", + "reference": "945d0b7f346a084ce5549e95289962972c4272e5", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.5.0 || ^2", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=7.3", + "phpunit/php-code-coverage": "^9.2.32", + "phpunit/php-file-iterator": "^3.0.6", + "phpunit/php-invoker": "^3.1.1", + "phpunit/php-text-template": "^2.0.4", + "phpunit/php-timer": "^5.0.3", + "sebastian/cli-parser": "^1.0.2", + "sebastian/code-unit": "^1.0.8", + "sebastian/comparator": "^4.0.9", + "sebastian/diff": "^4.0.6", + "sebastian/environment": "^5.1.5", + "sebastian/exporter": "^4.0.8", + "sebastian/global-state": "^5.0.8", + "sebastian/object-enumerator": "^4.0.4", + "sebastian/resource-operations": "^3.0.4", + "sebastian/type": "^3.2.1", + "sebastian/version": "^3.0.2" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "9.6-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2025-12-06T07:45:52+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "reference": "2b56bea83a09de3ac06bb18b92f068e60cc6f50b", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:27:43+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "1.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", + "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:08:54+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T05:30:19+00:00" + }, + { + "name": "sebastian/comparator", + "version": "4.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/diff": "^4.0", + "sebastian/exporter": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:51:50+00:00" + }, + { + "name": "sebastian/complexity", + "version": "2.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a", + "reference": "25f207c40d62b8b7aa32f5ab026c53561964053a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:19:30+00:00" + }, + { + "name": "sebastian/diff", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc", + "reference": "ba01945089c3a293b01ba9badc29ad55b106b0bc", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3", + "symfony/process": "^4.2 || ^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "source": "https://github.com/sebastianbergmann/diff/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T06:30:58+00:00" + }, + { + "name": "sebastian/environment", + "version": "5.1.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:03:51+00:00" + }, + { + "name": "sebastian/exporter", + "version": "4.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2025-09-24T06:03:27+00:00" + }, + { + "name": "sebastian/global-state", + "version": "5.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^9.3" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2025-08-10T07:10:35+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "reference": "e1e4a170560925c26d424b6a03aed157e7dcc5c5", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-22T06:20:34+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", + "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "shasum": "" + }, + "require": { + "php": ">=7.3", + "sebastian/object-reflector": "^2.0", + "sebastian/recursion-context": "^4.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:12:34+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-10-26T13:14:26+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "4.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0", + "reference": "539c6691e0623af6dc6f9c20384c120f963465a0", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-10T06:57:39+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "reference": "05d5692a7993ecccd56a03e40cd7e5b09b1d404e", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "support": { + "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-14T16:00:52+00:00" + }, + { + "name": "sebastian/type", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:13:03+00:00" + }, + { + "name": "sebastian/version", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c6c1022351a901512170118436c764e473f6de8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", + "reference": "c6c1022351a901512170118436c764e473f6de8c", + "shasum": "" + }, + "require": { + "php": ">=7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/3.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2020-09-28T06:39:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-11-17T20:03:58+00:00" + } + ], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, diff --git a/examples/send_bms_free_carousel_feed.php b/examples/send_bms_free_carousel_feed.php new file mode 100644 index 0000000..c95d006 --- /dev/null +++ b/examples/send_bms_free_carousel_feed.php @@ -0,0 +1,75 @@ +uploadFile(__DIR__ . '/images/example-2to1.jpg', 'BMS_CAROUSEL_FEED_LIST'); + +$button1 = new BmsButton(); +$button1->setLinkType('WL') + ->setName('레시피 보기') + ->setLinkMobile('https://example.com/recipe1'); + +$item1 = new BmsCarouselFeedItem(); +$item1->setHeader('오늘의 브런치 레시피') + ->setContent('15분 만에 완성하는 아보카도 토스트! 간단하지만 영양 만점이에요.') + ->setImageId($imageId) + ->setButtons([$button1]); + +$button2 = new BmsButton(); +$button2->setLinkType('WL') + ->setName('영상 보기') + ->setLinkMobile('https://example.com/recipe2'); + +$item2 = new BmsCarouselFeedItem(); +$item2->setHeader('홈카페 꿀팁') + ->setContent('집에서 바리스타처럼! 라떼 아트 도전해보세요.') + ->setImageId($imageId) + ->setButtons([$button2]); + +$tail = new BmsCarouselTail(); +$tail->setLinkMobile('https://example.com/more'); + +$carousel = new BmsCarousel(); +$carousel->setList([$item1, $item2]) + ->setTail($tail); + +$bms = new KakaoBms(); +$bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::CAROUSEL_FEED) + ->setCarousel($carousel); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/send_bms_free_commerce.php b/examples/send_bms_free_commerce.php new file mode 100644 index 0000000..8e2a8af --- /dev/null +++ b/examples/send_bms_free_commerce.php @@ -0,0 +1,69 @@ +uploadFile(__DIR__ . '/images/product.jpg', 'BMS'); + +$commerce = new BmsCommerce(); +$commerce->setTitle('프리미엄 블루투스 스피커') + ->setRegularPrice(129000) + ->setDiscountPrice(99000) + ->setDiscountRate(23); + +$buyButton = new BmsButton(); +$buyButton->setLinkType('WL') + ->setName('지금 구매하기') + ->setLinkMobile('https://example.com/buy'); + +$coupon = new BmsCoupon(); +$coupon->setTitle('10000원 할인 쿠폰') + ->setDescription('첫 구매 고객 전용 쿠폰입니다.') + ->setLinkMobile('https://example.com/coupon'); + +$bms = new KakaoBms(); +$bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId($imageId) + ->setCommerce($commerce) + ->setButtons([$buyButton]) + ->setCoupon($coupon) + ->setAdditionalContent('무료배송 | 오늘 주문 시 내일 도착'); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/send_bms_free_image.php b/examples/send_bms_free_image.php new file mode 100644 index 0000000..351df04 --- /dev/null +++ b/examples/send_bms_free_image.php @@ -0,0 +1,48 @@ +uploadFile(__DIR__ . '/images/example.jpg', 'BMS'); + +$button = new BmsButton(); +$button->setLinkType('WL') + ->setName('자세히 보기') + ->setLinkMobile('https://example.com'); + +$bms = new KakaoBms(); +$bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::IMAGE) + ->setImageId($imageId) + ->setButtons([$button]); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setText('이미지와 함께하는 BMS 자유형 메시지입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/send_bms_free_premium_video.php b/examples/send_bms_free_premium_video.php new file mode 100644 index 0000000..3bff558 --- /dev/null +++ b/examples/send_bms_free_premium_video.php @@ -0,0 +1,60 @@ +setVideoUrl('https://tv.kakao.com/v/460734285'); + +$button = new BmsButton(); +$button->setLinkType('WL') + ->setName('더 많은 영상 보기') + ->setLinkMobile('https://example.com/videos'); + +$coupon = new BmsCoupon(); +$coupon->setTitle('10% 할인 쿠폰') + ->setDescription('영화 예매 시 사용 가능한 할인 쿠폰입니다.') + ->setLinkMobile('https://example.com/coupon'); + +$bms = new KakaoBms(); +$bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::PREMIUM_VIDEO) + ->setHeader('이 주의 추천 영화') + ->setContent('2024년 최고의 액션 블록버스터! 지금 바로 예고편을 확인해보세요.') + ->setVideo($video) + ->setButtons([$button]) + ->setCoupon($coupon); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setText('주말 영화 추천!\n\n올해 가장 화제가 된 영화를 미리 만나보세요.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/send_bms_free_text.php b/examples/send_bms_free_text.php new file mode 100644 index 0000000..cf7cd76 --- /dev/null +++ b/examples/send_bms_free_text.php @@ -0,0 +1,40 @@ +setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::TEXT); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setText('안녕하세요! BMS 자유형 TEXT 메시지입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/examples/send_bms_free_wide_item_list.php b/examples/send_bms_free_wide_item_list.php new file mode 100644 index 0000000..9e2b6b0 --- /dev/null +++ b/examples/send_bms_free_wide_item_list.php @@ -0,0 +1,70 @@ +uploadFile(__DIR__ . '/images/main-2to1.jpg', 'BMS_WIDE_MAIN_ITEM_LIST'); +$subImageId = $messageService->uploadFile(__DIR__ . '/images/sub-1to1.jpg', 'BMS_WIDE_SUB_ITEM_LIST'); + +$mainItem = new BmsMainWideItem(); +$mainItem->setTitle('이번 주 베스트셀러') + ->setImageId($mainImageId) + ->setLinkMobile('https://example.com/bestseller'); + +$subItem1 = new BmsSubWideItem(); +$subItem1->setTitle('프리미엄 헤드폰') + ->setImageId($subImageId) + ->setLinkMobile('https://example.com/item1'); + +$subItem2 = new BmsSubWideItem(); +$subItem2->setTitle('무선 이어폰') + ->setImageId($subImageId) + ->setLinkMobile('https://example.com/item2'); + +$subItem3 = new BmsSubWideItem(); +$subItem3->setTitle('블루투스 스피커') + ->setImageId($subImageId) + ->setLinkMobile('https://example.com/item3'); + +$bms = new KakaoBms(); +$bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::WIDE_ITEM_LIST) + ->setHeader('홍길동님을 위한 추천 상품') + ->setMainWideItem($mainItem) + ->setSubWideItemList([$subItem1, $subItem2, $subItem3]); + +$kakaoOption = new KakaoOption(); +$kakaoOption->setPfId('연동한 비즈니스 채널의 pfId') + ->setBms($bms); + +$message = new Message(); +$message->setTo('수신번호') + ->setFrom('발신번호') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + +try { + $response = $messageService->send($message); + print_r($response); +} catch (Exception $e) { + echo "Error: " . $e->getMessage(); +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..c249751 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests + + + + + src + + + diff --git a/src/Exceptions/BmsValidationException.php b/src/Exceptions/BmsValidationException.php new file mode 100644 index 0000000..8858fcf --- /dev/null +++ b/src/Exceptions/BmsValidationException.php @@ -0,0 +1,11 @@ +linkType; + } + + /** + * @param string $linkType + * @return BmsButton + */ + public function setLinkType(string $linkType): BmsButton + { + $this->linkType = $linkType; + return $this; + } + + /** + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } + + /** + * @param string|null $name + * @return BmsButton + */ + public function setName(?string $name): BmsButton + { + $this->name = $name; + return $this; + } + + /** + * @return string|null + */ + public function getLinkMobile(): ?string + { + return $this->linkMobile; + } + + /** + * @param string|null $linkMobile + * @return BmsButton + */ + public function setLinkMobile(?string $linkMobile): BmsButton + { + $this->linkMobile = $linkMobile; + return $this; + } + + /** + * @return string|null + */ + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + /** + * @param string|null $linkPc + * @return BmsButton + */ + public function setLinkPc(?string $linkPc): BmsButton + { + $this->linkPc = $linkPc; + return $this; + } + + /** + * @return string|null + */ + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + /** + * @param string|null $linkAndroid + * @return BmsButton + */ + public function setLinkAndroid(?string $linkAndroid): BmsButton + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + /** + * @return string|null + */ + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + /** + * @param string|null $linkIos + * @return BmsButton + */ + public function setLinkIos(?string $linkIos): BmsButton + { + $this->linkIos = $linkIos; + return $this; + } + + /** + * @return bool|null + */ + public function getTargetOut(): ?bool + { + return $this->targetOut; + } + + /** + * @param bool|null $targetOut + * @return BmsButton + */ + public function setTargetOut(?bool $targetOut): BmsButton + { + $this->targetOut = $targetOut; + return $this; + } + + /** + * @return string|null + */ + public function getChatExtra(): ?string + { + return $this->chatExtra; + } + + /** + * @param string|null $chatExtra + * @return BmsButton + */ + public function setChatExtra(?string $chatExtra): BmsButton + { + $this->chatExtra = $chatExtra; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsButtonLinkType.php b/src/Models/Kakao/Bms/BmsButtonLinkType.php new file mode 100644 index 0000000..4153079 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsButtonLinkType.php @@ -0,0 +1,82 @@ +head; + } + + public function setHead(?BmsCarouselHead $head): BmsCarousel + { + $this->head = $head; + return $this; + } + + public function getList(): array + { + return $this->list; + } + + public function setList(array $list): BmsCarousel + { + $this->list = $list; + return $this; + } + + public function getTail(): ?BmsCarouselTail + { + return $this->tail; + } + + public function setTail(?BmsCarouselTail $tail): BmsCarousel + { + $this->tail = $tail; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsCarouselCommerceItem.php b/src/Models/Kakao/Bms/BmsCarouselCommerceItem.php new file mode 100644 index 0000000..f98f513 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsCarouselCommerceItem.php @@ -0,0 +1,90 @@ +commerce; + } + + public function setCommerce(BmsCommerce $commerce): BmsCarouselCommerceItem + { + $this->commerce = $commerce; + return $this; + } + + public function getImageId(): string + { + return $this->imageId; + } + + public function setImageId(string $imageId): BmsCarouselCommerceItem + { + $this->imageId = $imageId; + return $this; + } + + public function getImageLink(): ?string + { + return $this->imageLink; + } + + public function setImageLink(?string $imageLink): BmsCarouselCommerceItem + { + $this->imageLink = $imageLink; + return $this; + } + + public function getButtons(): array + { + return $this->buttons; + } + + public function setButtons(array $buttons): BmsCarouselCommerceItem + { + $this->buttons = $buttons; + return $this; + } + + public function getAdditionalContent(): ?string + { + return $this->additionalContent; + } + + public function setAdditionalContent(?string $additionalContent): BmsCarouselCommerceItem + { + $this->additionalContent = $additionalContent; + return $this; + } + + public function getCoupon(): ?BmsCoupon + { + return $this->coupon; + } + + public function setCoupon(?BmsCoupon $coupon): BmsCarouselCommerceItem + { + $this->coupon = $coupon; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsCarouselFeedItem.php b/src/Models/Kakao/Bms/BmsCarouselFeedItem.php new file mode 100644 index 0000000..6bee67e --- /dev/null +++ b/src/Models/Kakao/Bms/BmsCarouselFeedItem.php @@ -0,0 +1,90 @@ +header; + } + + public function setHeader(string $header): BmsCarouselFeedItem + { + $this->header = $header; + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): BmsCarouselFeedItem + { + $this->content = $content; + return $this; + } + + public function getImageId(): string + { + return $this->imageId; + } + + public function setImageId(string $imageId): BmsCarouselFeedItem + { + $this->imageId = $imageId; + return $this; + } + + public function getImageLink(): ?string + { + return $this->imageLink; + } + + public function setImageLink(?string $imageLink): BmsCarouselFeedItem + { + $this->imageLink = $imageLink; + return $this; + } + + public function getButtons(): array + { + return $this->buttons; + } + + public function setButtons(array $buttons): BmsCarouselFeedItem + { + $this->buttons = $buttons; + return $this; + } + + public function getCoupon(): ?BmsCoupon + { + return $this->coupon; + } + + public function setCoupon(?BmsCoupon $coupon): BmsCarouselFeedItem + { + $this->coupon = $coupon; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsCarouselHead.php b/src/Models/Kakao/Bms/BmsCarouselHead.php new file mode 100644 index 0000000..c34cb7c --- /dev/null +++ b/src/Models/Kakao/Bms/BmsCarouselHead.php @@ -0,0 +1,104 @@ +header; + } + + public function setHeader(string $header): BmsCarouselHead + { + $this->header = $header; + return $this; + } + + public function getContent(): string + { + return $this->content; + } + + public function setContent(string $content): BmsCarouselHead + { + $this->content = $content; + return $this; + } + + public function getImageId(): string + { + return $this->imageId; + } + + public function setImageId(string $imageId): BmsCarouselHead + { + $this->imageId = $imageId; + return $this; + } + + public function getLinkMobile(): ?string + { + return $this->linkMobile; + } + + public function setLinkMobile(?string $linkMobile): BmsCarouselHead + { + $this->linkMobile = $linkMobile; + return $this; + } + + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + public function setLinkPc(?string $linkPc): BmsCarouselHead + { + $this->linkPc = $linkPc; + return $this; + } + + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + public function setLinkAndroid(?string $linkAndroid): BmsCarouselHead + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + public function setLinkIos(?string $linkIos): BmsCarouselHead + { + $this->linkIos = $linkIos; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsCarouselTail.php b/src/Models/Kakao/Bms/BmsCarouselTail.php new file mode 100644 index 0000000..ac5419b --- /dev/null +++ b/src/Models/Kakao/Bms/BmsCarouselTail.php @@ -0,0 +1,62 @@ +linkMobile; + } + + public function setLinkMobile(string $linkMobile): BmsCarouselTail + { + $this->linkMobile = $linkMobile; + return $this; + } + + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + public function setLinkPc(?string $linkPc): BmsCarouselTail + { + $this->linkPc = $linkPc; + return $this; + } + + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + public function setLinkAndroid(?string $linkAndroid): BmsCarouselTail + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + public function setLinkIos(?string $linkIos): BmsCarouselTail + { + $this->linkIos = $linkIos; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsChatBubbleType.php b/src/Models/Kakao/Bms/BmsChatBubbleType.php new file mode 100644 index 0000000..1c9b375 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsChatBubbleType.php @@ -0,0 +1,70 @@ +title; + } + + /** + * @param string $title + * @return BmsCommerce + */ + public function setTitle(string $title): BmsCommerce + { + $this->title = $title; + return $this; + } + + /** + * @return int|float + */ + public function getRegularPrice() + { + return $this->regularPrice; + } + + /** + * @param int|float $regularPrice + * @return BmsCommerce + */ + public function setRegularPrice($regularPrice): BmsCommerce + { + $this->regularPrice = $regularPrice; + return $this; + } + + /** + * @return int|float|null + */ + public function getDiscountPrice() + { + return $this->discountPrice; + } + + /** + * @param int|float|null $discountPrice + * @return BmsCommerce + */ + public function setDiscountPrice($discountPrice): BmsCommerce + { + $this->discountPrice = $discountPrice; + return $this; + } + + /** + * @return int|float|null + */ + public function getDiscountRate() + { + return $this->discountRate; + } + + /** + * @param int|float|null $discountRate + * @return BmsCommerce + */ + public function setDiscountRate($discountRate): BmsCommerce + { + $this->discountRate = $discountRate; + return $this; + } + + /** + * @return int|float|null + */ + public function getDiscountFixed() + { + return $this->discountFixed; + } + + /** + * @param int|float|null $discountFixed + * @return BmsCommerce + */ + public function setDiscountFixed($discountFixed): BmsCommerce + { + $this->discountFixed = $discountFixed; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsCoupon.php b/src/Models/Kakao/Bms/BmsCoupon.php new file mode 100644 index 0000000..7b547c5 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsCoupon.php @@ -0,0 +1,154 @@ +title; + } + + /** + * @param string $title + * @return BmsCoupon + */ + public function setTitle(string $title): BmsCoupon + { + $this->title = $title; + return $this; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @param string $description + * @return BmsCoupon + */ + public function setDescription(string $description): BmsCoupon + { + $this->description = $description; + return $this; + } + + /** + * @return string|null + */ + public function getLinkMobile(): ?string + { + return $this->linkMobile; + } + + /** + * @param string|null $linkMobile + * @return BmsCoupon + */ + public function setLinkMobile(?string $linkMobile): BmsCoupon + { + $this->linkMobile = $linkMobile; + return $this; + } + + /** + * @return string|null + */ + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + /** + * @param string|null $linkPc + * @return BmsCoupon + */ + public function setLinkPc(?string $linkPc): BmsCoupon + { + $this->linkPc = $linkPc; + return $this; + } + + /** + * @return string|null + */ + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + /** + * @param string|null $linkAndroid + * @return BmsCoupon + */ + public function setLinkAndroid(?string $linkAndroid): BmsCoupon + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + /** + * @return string|null + */ + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + /** + * @param string|null $linkIos + * @return BmsCoupon + */ + public function setLinkIos(?string $linkIos): BmsCoupon + { + $this->linkIos = $linkIos; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsMainWideItem.php b/src/Models/Kakao/Bms/BmsMainWideItem.php new file mode 100644 index 0000000..056a521 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsMainWideItem.php @@ -0,0 +1,90 @@ +title; + } + + public function setTitle(?string $title): BmsMainWideItem + { + $this->title = $title; + return $this; + } + + public function getImageId(): string + { + return $this->imageId; + } + + public function setImageId(string $imageId): BmsMainWideItem + { + $this->imageId = $imageId; + return $this; + } + + public function getLinkMobile(): string + { + return $this->linkMobile; + } + + public function setLinkMobile(string $linkMobile): BmsMainWideItem + { + $this->linkMobile = $linkMobile; + return $this; + } + + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + public function setLinkPc(?string $linkPc): BmsMainWideItem + { + $this->linkPc = $linkPc; + return $this; + } + + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + public function setLinkAndroid(?string $linkAndroid): BmsMainWideItem + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + public function setLinkIos(?string $linkIos): BmsMainWideItem + { + $this->linkIos = $linkIos; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsSubWideItem.php b/src/Models/Kakao/Bms/BmsSubWideItem.php new file mode 100644 index 0000000..57a9704 --- /dev/null +++ b/src/Models/Kakao/Bms/BmsSubWideItem.php @@ -0,0 +1,90 @@ +title; + } + + public function setTitle(string $title): BmsSubWideItem + { + $this->title = $title; + return $this; + } + + public function getImageId(): string + { + return $this->imageId; + } + + public function setImageId(string $imageId): BmsSubWideItem + { + $this->imageId = $imageId; + return $this; + } + + public function getLinkMobile(): string + { + return $this->linkMobile; + } + + public function setLinkMobile(string $linkMobile): BmsSubWideItem + { + $this->linkMobile = $linkMobile; + return $this; + } + + public function getLinkPc(): ?string + { + return $this->linkPc; + } + + public function setLinkPc(?string $linkPc): BmsSubWideItem + { + $this->linkPc = $linkPc; + return $this; + } + + public function getLinkAndroid(): ?string + { + return $this->linkAndroid; + } + + public function setLinkAndroid(?string $linkAndroid): BmsSubWideItem + { + $this->linkAndroid = $linkAndroid; + return $this; + } + + public function getLinkIos(): ?string + { + return $this->linkIos; + } + + public function setLinkIos(?string $linkIos): BmsSubWideItem + { + $this->linkIos = $linkIos; + return $this; + } +} diff --git a/src/Models/Kakao/Bms/BmsValidator.php b/src/Models/Kakao/Bms/BmsValidator.php new file mode 100644 index 0000000..eadf05e --- /dev/null +++ b/src/Models/Kakao/Bms/BmsValidator.php @@ -0,0 +1,111 @@ + [], + BmsChatBubbleType::IMAGE => ['imageId'], + BmsChatBubbleType::WIDE => ['imageId'], + BmsChatBubbleType::WIDE_ITEM_LIST => ['header', 'mainWideItem', 'subWideItemList'], + BmsChatBubbleType::COMMERCE => ['imageId', 'commerce', 'buttons'], + BmsChatBubbleType::CAROUSEL_FEED => ['carousel'], + BmsChatBubbleType::CAROUSEL_COMMERCE => ['carousel'], + BmsChatBubbleType::PREMIUM_VIDEO => ['video'], + ]; + + public static function validate(KakaoBms $bms): void + { + if ($bms->chatBubbleType === null) { + return; + } + + self::validateRequiredFields($bms); + self::validateWideItemList($bms); + self::validateCommercePricing($bms); + self::validateVideoUrl($bms); + } + + private static function validateRequiredFields(KakaoBms $bms): void + { + $requiredFields = self::REQUIRED_FIELDS[$bms->chatBubbleType] ?? []; + $missingFields = []; + + foreach ($requiredFields as $field) { + if ($bms->$field === null) { + $missingFields[] = $field; + } + } + + if (!empty($missingFields)) { + throw new BmsValidationException( + "BMS {$bms->chatBubbleType} 타입에 필수 필드가 누락되었습니다: " . implode(', ', $missingFields) + ); + } + } + + private static function validateWideItemList(KakaoBms $bms): void + { + if ($bms->chatBubbleType !== BmsChatBubbleType::WIDE_ITEM_LIST) { + return; + } + + $count = $bms->subWideItemList !== null ? count($bms->subWideItemList) : 0; + if ($count < self::WIDE_ITEM_LIST_MIN_SUB_ITEMS) { + throw new BmsValidationException( + "WIDE_ITEM_LIST 타입의 subWideItemList는 최소 " . self::WIDE_ITEM_LIST_MIN_SUB_ITEMS . + "개 이상이어야 합니다. 현재: {$count}개" + ); + } + } + + private static function validateCommercePricing(KakaoBms $bms): void + { + if ($bms->commerce === null) { + return; + } + + $commerce = $bms->commerce; + $hasDiscountPrice = $commerce->discountPrice !== null; + $hasDiscountRate = $commerce->discountRate !== null; + $hasDiscountFixed = $commerce->discountFixed !== null; + + if ($hasDiscountRate && $hasDiscountFixed) { + throw new BmsValidationException( + 'discountRate와 discountFixed는 동시에 사용할 수 없습니다.' + ); + } + + if (!$hasDiscountPrice && ($hasDiscountRate || $hasDiscountFixed)) { + throw new BmsValidationException( + 'discountRate 또는 discountFixed를 사용하려면 discountPrice도 함께 지정해야 합니다.' + ); + } + + if ($hasDiscountPrice && !$hasDiscountRate && !$hasDiscountFixed) { + throw new BmsValidationException( + 'discountPrice를 사용하려면 discountRate 또는 discountFixed 중 하나를 함께 지정해야 합니다.' + ); + } + } + + private static function validateVideoUrl(KakaoBms $bms): void + { + if ($bms->video === null || $bms->video->videoUrl === null) { + return; + } + + if (strpos($bms->video->videoUrl, self::KAKAO_TV_URL_PREFIX) !== 0) { + throw new BmsValidationException( + "videoUrl은 '" . self::KAKAO_TV_URL_PREFIX . "'으로 시작해야 합니다." + ); + } + } +} diff --git a/src/Models/Kakao/Bms/BmsVideo.php b/src/Models/Kakao/Bms/BmsVideo.php new file mode 100644 index 0000000..61e7e4b --- /dev/null +++ b/src/Models/Kakao/Bms/BmsVideo.php @@ -0,0 +1,81 @@ +videoUrl; + } + + /** + * @param string $videoUrl + * @return BmsVideo + */ + public function setVideoUrl(string $videoUrl): BmsVideo + { + $this->videoUrl = $videoUrl; + return $this; + } + + /** + * @return string|null + */ + public function getImageId(): ?string + { + return $this->imageId; + } + + /** + * @param string|null $imageId + * @return BmsVideo + */ + public function setImageId(?string $imageId): BmsVideo + { + $this->imageId = $imageId; + return $this; + } + + /** + * @return string|null + */ + public function getImageLink(): ?string + { + return $this->imageLink; + } + + /** + * @param string|null $imageLink + * @return BmsVideo + */ + public function setImageLink(?string $imageLink): BmsVideo + { + $this->imageLink = $imageLink; + return $this; + } +} diff --git a/src/Models/Kakao/KakaoBms.php b/src/Models/Kakao/KakaoBms.php index e24cd63..d6ba3f1 100644 --- a/src/Models/Kakao/KakaoBms.php +++ b/src/Models/Kakao/KakaoBms.php @@ -2,31 +2,247 @@ namespace Nurigo\Solapi\Models\Kakao; -class KakaoBms { +use Nurigo\Solapi\Models\Kakao\Bms\BmsButton; +use Nurigo\Solapi\Models\Kakao\Bms\BmsCarousel; +use Nurigo\Solapi\Models\Kakao\Bms\BmsCommerce; +use Nurigo\Solapi\Models\Kakao\Bms\BmsCoupon; +use Nurigo\Solapi\Models\Kakao\Bms\BmsMainWideItem; +use Nurigo\Solapi\Models\Kakao\Bms\BmsSubWideItem; +use Nurigo\Solapi\Models\Kakao\Bms\BmsVideo; + +/** + * 카카오 BMS(브랜드 메시지 서비스) 옵션 + * + * BMS 자유형 메시지 발송 시 chatBubbleType별 필수 필드: + * - TEXT: (text는 Message의 text 필드 사용) + * - IMAGE: imageId 필수 + * - WIDE: imageId 필수 + * - WIDE_ITEM_LIST: header, mainWideItem, subWideItemList(최소 3개) 필수 + * - COMMERCE: imageId, commerce, buttons 필수 + * - CAROUSEL_FEED: carousel 필수 + * - CAROUSEL_COMMERCE: carousel 필수 + * - PREMIUM_VIDEO: video 필수 + * + * @see BmsChatBubbleType + */ +class KakaoBms +{ /** - * @var string M, N, I 값만 허용됩니다, M, N 값은 카카오 측에 별도 인허가를 받은 비즈니스 채널만 이용하실 수 있습니다. + * @var string 타겟팅 타입 (M, N, I) * M: 마케팅 수신 동의자 + 카카오 비즈니스 채널 친구 대상으로 발송 * N: 마케팅 수신 동의자에게만 발송 * I: 카카오 비즈니스 채널 친구에게만 발송 + * @see KakaoBmsTargetingType */ public $targeting; /** - * @return string + * @var string|null 말풍선 타입 (TEXT, IMAGE, WIDE, WIDE_ITEM_LIST, COMMERCE, CAROUSEL_FEED, CAROUSEL_COMMERCE, PREMIUM_VIDEO) + * @see \Nurigo\Solapi\Models\Kakao\Bms\BmsChatBubbleType */ + public $chatBubbleType; + + /** @var bool|null 성인 전용 여부 */ + public $adult; + + /** @var string|null 헤더 텍스트 (WIDE_ITEM_LIST 타입용) */ + public $header; + + /** @var string|null 이미지 ID (IMAGE, WIDE, COMMERCE 타입용) */ + public $imageId; + + /** @var string|null 이미지 클릭 시 이동 링크 */ + public $imageLink; + + /** @var string|null 추가 콘텐츠 */ + public $additionalContent; + + /** @var string|null 본문 콘텐츠 (PREMIUM_VIDEO 타입용) */ + public $content; + + /** @var BmsButton[]|null 버튼 목록 */ + public $buttons; + + /** @var BmsCoupon|null 쿠폰 정보 */ + public $coupon; + + /** @var BmsCommerce|null 커머스(상품) 정보 */ + public $commerce; + + /** @var BmsVideo|null 비디오 정보 (PREMIUM_VIDEO 타입용) */ + public $video; + + /** @var BmsCarousel|null 캐러셀 정보 (CAROUSEL_FEED, CAROUSEL_COMMERCE 타입용) */ + public $carousel; + + /** @var BmsMainWideItem|null 메인 와이드 아이템 (WIDE_ITEM_LIST 타입용) */ + public $mainWideItem; + + /** @var BmsSubWideItem[]|null 서브 와이드 아이템 목록 (WIDE_ITEM_LIST 타입용, 최소 3개) */ + public $subWideItemList; + public function getTargeting(): string { return $this->targeting; } - /** - * @param string $targeting - */ public function setTargeting(string $targeting): KakaoBms { $this->targeting = $targeting; return $this; } + public function getChatBubbleType(): ?string + { + return $this->chatBubbleType; + } + + public function setChatBubbleType(?string $chatBubbleType): KakaoBms + { + $this->chatBubbleType = $chatBubbleType; + return $this; + } + + public function getAdult(): ?bool + { + return $this->adult; + } -} \ No newline at end of file + public function setAdult(?bool $adult): KakaoBms + { + $this->adult = $adult; + return $this; + } + + public function getHeader(): ?string + { + return $this->header; + } + + public function setHeader(?string $header): KakaoBms + { + $this->header = $header; + return $this; + } + + public function getImageId(): ?string + { + return $this->imageId; + } + + public function setImageId(?string $imageId): KakaoBms + { + $this->imageId = $imageId; + return $this; + } + + public function getImageLink(): ?string + { + return $this->imageLink; + } + + public function setImageLink(?string $imageLink): KakaoBms + { + $this->imageLink = $imageLink; + return $this; + } + + public function getAdditionalContent(): ?string + { + return $this->additionalContent; + } + + public function setAdditionalContent(?string $additionalContent): KakaoBms + { + $this->additionalContent = $additionalContent; + return $this; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): KakaoBms + { + $this->content = $content; + return $this; + } + + public function getButtons(): ?array + { + return $this->buttons; + } + + public function setButtons(?array $buttons): KakaoBms + { + $this->buttons = $buttons; + return $this; + } + + public function getCoupon(): ?BmsCoupon + { + return $this->coupon; + } + + public function setCoupon(?BmsCoupon $coupon): KakaoBms + { + $this->coupon = $coupon; + return $this; + } + + public function getCommerce(): ?BmsCommerce + { + return $this->commerce; + } + + public function setCommerce(?BmsCommerce $commerce): KakaoBms + { + $this->commerce = $commerce; + return $this; + } + + public function getVideo(): ?BmsVideo + { + return $this->video; + } + + public function setVideo(?BmsVideo $video): KakaoBms + { + $this->video = $video; + return $this; + } + + public function getCarousel(): ?BmsCarousel + { + return $this->carousel; + } + + public function setCarousel(?BmsCarousel $carousel): KakaoBms + { + $this->carousel = $carousel; + return $this; + } + + public function getMainWideItem(): ?BmsMainWideItem + { + return $this->mainWideItem; + } + + public function setMainWideItem(?BmsMainWideItem $mainWideItem): KakaoBms + { + $this->mainWideItem = $mainWideItem; + return $this; + } + + public function getSubWideItemList(): ?array + { + return $this->subWideItemList; + } + + public function setSubWideItemList(?array $subWideItemList): KakaoBms + { + $this->subWideItemList = $subWideItemList; + return $this; + } +} diff --git a/tests/Models/Kakao/Bms/BmsModelsTest.php b/tests/Models/Kakao/Bms/BmsModelsTest.php new file mode 100644 index 0000000..8254ad1 --- /dev/null +++ b/tests/Models/Kakao/Bms/BmsModelsTest.php @@ -0,0 +1,272 @@ +assertCount(8, $values); + $this->assertContains('TEXT', $values); + $this->assertContains('IMAGE', $values); + $this->assertContains('WIDE', $values); + $this->assertContains('WIDE_ITEM_LIST', $values); + $this->assertContains('COMMERCE', $values); + $this->assertContains('CAROUSEL_FEED', $values); + $this->assertContains('CAROUSEL_COMMERCE', $values); + $this->assertContains('PREMIUM_VIDEO', $values); + } + + public function testBmsButtonLinkTypeValues(): void + { + $values = BmsButtonLinkType::values(); + + $this->assertCount(8, $values); + $this->assertContains('AC', $values); + $this->assertContains('WL', $values); + $this->assertContains('AL', $values); + $this->assertContains('BK', $values); + $this->assertContains('MD', $values); + $this->assertContains('BC', $values); + $this->assertContains('BT', $values); + $this->assertContains('BF', $values); + } + + public function testBmsButtonLinkTypeCarouselAllowedTypes(): void + { + $allowed = BmsButtonLinkType::carouselAllowedTypes(); + + $this->assertCount(2, $allowed); + $this->assertContains('WL', $allowed); + $this->assertContains('AL', $allowed); + } + + public function testBmsButtonFluentSetters(): void + { + $button = new BmsButton(); + $result = $button + ->setLinkType('WL') + ->setName('Test Button') + ->setLinkMobile('https://m.example.com') + ->setLinkPc('https://example.com') + ->setTargetOut(true); + + $this->assertSame($button, $result); + $this->assertEquals('WL', $button->getLinkType()); + $this->assertEquals('Test Button', $button->getName()); + $this->assertEquals('https://m.example.com', $button->getLinkMobile()); + $this->assertEquals('https://example.com', $button->getLinkPc()); + $this->assertTrue($button->getTargetOut()); + } + + public function testBmsButtonAppLinkProperties(): void + { + $button = new BmsButton(); + $button + ->setLinkType('AL') + ->setName('App Button') + ->setLinkMobile('https://m.example.com') + ->setLinkAndroid('app://android') + ->setLinkIos('app://ios') + ->setChatExtra('extra-data'); + + $this->assertEquals('AL', $button->getLinkType()); + $this->assertEquals('app://android', $button->getLinkAndroid()); + $this->assertEquals('app://ios', $button->getLinkIos()); + $this->assertEquals('extra-data', $button->getChatExtra()); + } + + public function testBmsCommerceFluentSetters(): void + { + $commerce = new BmsCommerce(); + $result = $commerce + ->setTitle('Test Product') + ->setRegularPrice(50000) + ->setDiscountPrice(40000) + ->setDiscountRate(20) + ->setDiscountFixed(null); + + $this->assertSame($commerce, $result); + $this->assertEquals('Test Product', $commerce->getTitle()); + $this->assertEquals(50000, $commerce->getRegularPrice()); + $this->assertEquals(40000, $commerce->getDiscountPrice()); + $this->assertEquals(20, $commerce->getDiscountRate()); + $this->assertNull($commerce->getDiscountFixed()); + } + + public function testBmsCouponFluentSetters(): void + { + $coupon = new BmsCoupon(); + $result = $coupon + ->setTitle('10000원 할인 쿠폰') + ->setDescription('First purchase discount') + ->setLinkMobile('https://m.example.com/coupon') + ->setLinkPc('https://example.com/coupon') + ->setLinkAndroid('app://coupon') + ->setLinkIos('app://coupon'); + + $this->assertSame($coupon, $result); + $this->assertEquals('10000원 할인 쿠폰', $coupon->getTitle()); + $this->assertEquals('First purchase discount', $coupon->getDescription()); + $this->assertEquals('https://m.example.com/coupon', $coupon->getLinkMobile()); + } + + public function testBmsVideoFluentSetters(): void + { + $video = new BmsVideo(); + $result = $video + ->setVideoUrl('https://tv.kakao.com/v/123456') + ->setImageId('thumbnail-id') + ->setImageLink('https://example.com/video'); + + $this->assertSame($video, $result); + $this->assertEquals('https://tv.kakao.com/v/123456', $video->getVideoUrl()); + $this->assertEquals('thumbnail-id', $video->getImageId()); + $this->assertEquals('https://example.com/video', $video->getImageLink()); + } + + public function testBmsMainWideItemFluentSetters(): void + { + $item = new BmsMainWideItem(); + $result = $item + ->setTitle('Main Item') + ->setImageId('main-image') + ->setLinkMobile('https://m.example.com') + ->setLinkPc('https://example.com') + ->setLinkAndroid('app://android') + ->setLinkIos('app://ios'); + + $this->assertSame($item, $result); + $this->assertEquals('Main Item', $item->getTitle()); + $this->assertEquals('main-image', $item->getImageId()); + $this->assertEquals('https://m.example.com', $item->getLinkMobile()); + } + + public function testBmsSubWideItemFluentSetters(): void + { + $item = new BmsSubWideItem(); + $result = $item + ->setTitle('Sub Item') + ->setImageId('sub-image') + ->setLinkMobile('https://m.example.com'); + + $this->assertSame($item, $result); + $this->assertEquals('Sub Item', $item->getTitle()); + $this->assertEquals('sub-image', $item->getImageId()); + $this->assertEquals('https://m.example.com', $item->getLinkMobile()); + } + + public function testBmsCarouselHeadFluentSetters(): void + { + $head = new BmsCarouselHead(); + $result = $head + ->setHeader('Carousel Header') + ->setContent('Carousel description') + ->setImageId('header-image') + ->setLinkMobile('https://m.example.com'); + + $this->assertSame($head, $result); + $this->assertEquals('Carousel Header', $head->getHeader()); + $this->assertEquals('Carousel description', $head->getContent()); + $this->assertEquals('header-image', $head->getImageId()); + } + + public function testBmsCarouselTailFluentSetters(): void + { + $tail = new BmsCarouselTail(); + $result = $tail + ->setLinkMobile('https://m.example.com/more') + ->setLinkPc('https://example.com/more'); + + $this->assertSame($tail, $result); + $this->assertEquals('https://m.example.com/more', $tail->getLinkMobile()); + $this->assertEquals('https://example.com/more', $tail->getLinkPc()); + } + + public function testBmsCarouselFeedItemFluentSetters(): void + { + $button = new BmsButton(); + $button->setLinkType('WL')->setName('View')->setLinkMobile('https://m.example.com'); + + $coupon = new BmsCoupon(); + $coupon->setTitle('10% 할인 쿠폰')->setDescription('Discount'); + + $item = new BmsCarouselFeedItem(); + $result = $item + ->setHeader('Feed Item Header') + ->setContent('Feed item content') + ->setImageId('feed-image') + ->setImageLink('https://example.com/image') + ->setButtons([$button]) + ->setCoupon($coupon); + + $this->assertSame($item, $result); + $this->assertEquals('Feed Item Header', $item->getHeader()); + $this->assertEquals('Feed item content', $item->getContent()); + $this->assertCount(1, $item->getButtons()); + $this->assertNotNull($item->getCoupon()); + } + + public function testBmsCarouselCommerceItemFluentSetters(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Product')->setRegularPrice(10000); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://m.example.com'); + + $item = new BmsCarouselCommerceItem(); + $result = $item + ->setCommerce($commerce) + ->setImageId('product-image') + ->setImageLink('https://example.com/product') + ->setButtons([$button]) + ->setAdditionalContent('Free shipping') + ->setCoupon(null); + + $this->assertSame($item, $result); + $this->assertNotNull($item->getCommerce()); + $this->assertEquals('Product', $item->getCommerce()->getTitle()); + $this->assertEquals('Free shipping', $item->getAdditionalContent()); + } + + public function testBmsCarouselFluentSetters(): void + { + $head = new BmsCarouselHead(); + $head->setHeader('Header')->setContent('Content')->setImageId('head-img'); + + $feedItem = new BmsCarouselFeedItem(); + $feedItem->setHeader('Item')->setContent('Content')->setImageId('item-img')->setButtons([]); + + $tail = new BmsCarouselTail(); + $tail->setLinkMobile('https://m.example.com/more'); + + $carousel = new BmsCarousel(); + $result = $carousel + ->setHead($head) + ->setList([$feedItem]) + ->setTail($tail); + + $this->assertSame($carousel, $result); + $this->assertNotNull($carousel->getHead()); + $this->assertCount(1, $carousel->getList()); + $this->assertNotNull($carousel->getTail()); + } +} diff --git a/tests/Models/Kakao/Bms/BmsValidatorTest.php b/tests/Models/Kakao/Bms/BmsValidatorTest.php new file mode 100644 index 0000000..60f3571 --- /dev/null +++ b/tests/Models/Kakao/Bms/BmsValidatorTest.php @@ -0,0 +1,300 @@ +setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::TEXT); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testImageTypeRequiresImageId(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::IMAGE); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('imageId'); + BmsValidator::validate($bms); + } + + public function testImageTypePassesWithImageId(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::IMAGE) + ->setImageId('test-image-id'); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testWideItemListRequiresMinimumThreeSubItems(): void + { + $mainItem = new BmsMainWideItem(); + $mainItem->setImageId('img1')->setLinkMobile('https://example.com'); + + $subItem1 = new BmsSubWideItem(); + $subItem1->setTitle('Item 1')->setImageId('img1')->setLinkMobile('https://example.com'); + + $subItem2 = new BmsSubWideItem(); + $subItem2->setTitle('Item 2')->setImageId('img2')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::WIDE_ITEM_LIST) + ->setHeader('Test Header') + ->setMainWideItem($mainItem) + ->setSubWideItemList([$subItem1, $subItem2]); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('최소 3개'); + BmsValidator::validate($bms); + } + + public function testWideItemListPassesWithThreeSubItems(): void + { + $mainItem = new BmsMainWideItem(); + $mainItem->setImageId('img1')->setLinkMobile('https://example.com'); + + $subItems = []; + for ($i = 1; $i <= 3; $i++) { + $subItem = new BmsSubWideItem(); + $subItem->setTitle("Item $i")->setImageId("img$i")->setLinkMobile('https://example.com'); + $subItems[] = $subItem; + } + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::WIDE_ITEM_LIST) + ->setHeader('Test Header') + ->setMainWideItem($mainItem) + ->setSubWideItemList($subItems); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testCommerceTypeRequiresFields(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE); + + $this->expectException(BmsValidationException::class); + BmsValidator::validate($bms); + } + + public function testCommerceTypePassesWithAllFields(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product')->setRegularPrice(10000); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testCommercePricingCannotUseBothDiscountRateAndFixed(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product') + ->setRegularPrice(10000) + ->setDiscountPrice(8000) + ->setDiscountRate(20) + ->setDiscountFixed(2000); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('discountRate와 discountFixed는 동시에 사용할 수 없습니다'); + BmsValidator::validate($bms); + } + + public function testCommercePricingRequiresDiscountPriceWithDiscountRate(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product') + ->setRegularPrice(10000) + ->setDiscountRate(20); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('discountPrice도 함께 지정해야 합니다'); + BmsValidator::validate($bms); + } + + public function testCommercePricingRequiresDiscountTypeWithDiscountPrice(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product') + ->setRegularPrice(10000) + ->setDiscountPrice(8000); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('discountRate 또는 discountFixed 중 하나를 함께 지정해야 합니다'); + BmsValidator::validate($bms); + } + + public function testCommercePricingPassesWithValidDiscountRate(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product') + ->setRegularPrice(10000) + ->setDiscountPrice(8000) + ->setDiscountRate(20); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testCommercePricingPassesWithValidDiscountFixed(): void + { + $commerce = new BmsCommerce(); + $commerce->setTitle('Test Product') + ->setRegularPrice(10000) + ->setDiscountPrice(8000) + ->setDiscountFixed(2000); + + $button = new BmsButton(); + $button->setLinkType('WL')->setName('Buy')->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId('test-image') + ->setCommerce($commerce) + ->setButtons([$button]); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testPremiumVideoRequiresKakaoTvUrl(): void + { + $video = new BmsVideo(); + $video->setVideoUrl('https://youtube.com/watch?v=123'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::PREMIUM_VIDEO) + ->setVideo($video); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('https://tv.kakao.com/'); + BmsValidator::validate($bms); + } + + public function testPremiumVideoPassesWithKakaoTvUrl(): void + { + $video = new BmsVideo(); + $video->setVideoUrl('https://tv.kakao.com/v/123456'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::PREMIUM_VIDEO) + ->setVideo($video); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testCarouselFeedRequiresCarousel(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::CAROUSEL_FEED); + + $this->expectException(BmsValidationException::class); + $this->expectExceptionMessage('carousel'); + BmsValidator::validate($bms); + } + + public function testCarouselFeedPassesWithCarousel(): void + { + $carousel = new BmsCarousel(); + $carousel->setList([]); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::CAROUSEL_FEED) + ->setCarousel($carousel); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } + + public function testNullChatBubbleTypeSkipsValidation(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I'); + + BmsValidator::validate($bms); + $this->assertTrue(true); + } +} From 90580130c36eb6be8928f4e2ac335f9925b936c8 Mon Sep 17 00:00:00 2001 From: Subin Lee Date: Fri, 23 Jan 2026 12:45:56 +0900 Subject: [PATCH 6/6] Enhance testing and configuration for Kakao BMS in SOLAPI PHP SDK - Updated .gitignore to exclude .env and .omc files. - Added new PHPUnit test scripts for unit and end-to-end testing of BMS message types. - Modified composer.json to include additional test scripts for unit, end-to-end, and coverage testing. - Updated phpunit.xml to define separate test suites for Unit and E2E tests. - Introduced a bootstrap file for loading environment variables and Composer autoloading. --- .gitignore | 6 +- composer.json | 5 +- phpunit.xml | 9 +- tests/E2E/BmsFreeSendTest.php | 592 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 32 ++ 5 files changed, 639 insertions(+), 5 deletions(-) create mode 100644 tests/E2E/BmsFreeSendTest.php create mode 100644 tests/bootstrap.php diff --git a/.gitignore b/.gitignore index 207cecd..023a2fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .vscode/ +.env log.txt out/ *.swp @@ -15,4 +16,7 @@ composer.phar # PHPUnit .phpunit.result.cache -.phpunit.cache/ \ No newline at end of file +.phpunit.cache/ + +# omc +.omc/ \ No newline at end of file diff --git a/composer.json b/composer.json index 832cbd3..151a5e3 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,9 @@ "phpunit/phpunit": "^9.5" }, "scripts": { - "test": "phpunit" + "test": "phpunit", + "test:unit": "phpunit --testsuite=Unit", + "test:e2e": "phpunit --testsuite=E2E", + "test:coverage": "phpunit --coverage-text" } } diff --git a/phpunit.xml b/phpunit.xml index c249751..6ac9d55 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,12 +1,15 @@ - - tests + + tests/Models + + + tests/E2E diff --git a/tests/E2E/BmsFreeSendTest.php b/tests/E2E/BmsFreeSendTest.php new file mode 100644 index 0000000..5a8c390 --- /dev/null +++ b/tests/E2E/BmsFreeSendTest.php @@ -0,0 +1,592 @@ +pfId = getenv('SOLAPI_KAKAO_PF_ID') ?: ''; + $this->senderNumber = getenv('SOLAPI_SENDER_NUMBER') ?: ''; + $this->recipientNumber = getenv('SOLAPI_RECIPIENT_NUMBER') ?: ''; + $this->testImagePath = getenv('SOLAPI_TEST_IMAGE_PATH') ?: null; + + if (!$apiKey || !$apiSecret) { + $this->markTestSkipped('SOLAPI_API_KEY and SOLAPI_API_SECRET environment variables are required'); + } + + if (!$this->pfId) { + $this->markTestSkipped('SOLAPI_KAKAO_PF_ID environment variable is required'); + } + + if (!$this->senderNumber || !$this->recipientNumber) { + $this->markTestSkipped('SOLAPI_SENDER_NUMBER and SOLAPI_RECIPIENT_NUMBER environment variables are required'); + } + + $this->messageService = new SolapiMessageService($apiKey, $apiSecret); + } + + /** + * Test sending BMS FREE TEXT type with minimal structure + */ + public function testSendBmsTextMinimal(): void + { + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::TEXT); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setText('[E2E 테스트] BMS FREE TEXT 최소 구조 테스트입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + try { + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE TEXT test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE TEXT type with buttons and coupon + */ + public function testSendBmsTextWithButtons(): void + { + $webButton = new BmsButton(); + $webButton->setLinkType('WL') + ->setName('웹 링크') + ->setLinkMobile('https://example.com'); + + $appButton = new BmsButton(); + $appButton->setLinkType('AL') + ->setName('앱 링크') + ->setLinkMobile('https://example.com') + ->setLinkAndroid('exampleapp://path') + ->setLinkIos('exampleapp://path'); + + $coupon = new BmsCoupon(); + $coupon->setTitle('10% 할인 쿠폰') + ->setDescription('테스트 쿠폰') + ->setLinkMobile('https://example.com/coupon'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::TEXT) + ->setAdult(false) + ->setButtons([$webButton, $appButton]) + ->setCoupon($coupon); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setText('[E2E 테스트] BMS FREE TEXT 전체 필드 테스트입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + try { + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE TEXT with buttons test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE IMAGE type with image upload + */ + public function testSendBmsImage(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + $imageId = $this->messageService->uploadFile($imagePath, 'BMS'); + echo sprintf("\nUploaded BMS image ID: %s\n", $imageId); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::IMAGE) + ->setImageId($imageId); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setText('[E2E 테스트] BMS FREE IMAGE 테스트입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE IMAGE test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE WIDE type + */ + public function testSendBmsWide(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + $imageId = $this->messageService->uploadFile($imagePath, 'BMS_WIDE'); + echo sprintf("\nUploaded BMS WIDE image ID: %s\n", $imageId); + + $button = new BmsButton(); + $button->setLinkType('WL') + ->setName('자세히 보기') + ->setLinkMobile('https://example.com'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::WIDE) + ->setImageId($imageId) + ->setButtons([$button]); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setText('[E2E 테스트] BMS FREE WIDE 테스트입니다.') + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE WIDE test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE COMMERCE type with product info + */ + public function testSendBmsCommerce(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + $imageId = $this->messageService->uploadFile($imagePath, 'BMS'); + + $commerce = new BmsCommerce(); + $commerce->setTitle('테스트 상품') + ->setRegularPrice(50000) + ->setDiscountPrice(40000) + ->setDiscountRate(20); + + $button = new BmsButton(); + $button->setLinkType('WL') + ->setName('구매하기') + ->setLinkMobile('https://example.com/product'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::COMMERCE) + ->setImageId($imageId) + ->setCommerce($commerce) + ->setButtons([$button]); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE COMMERCE test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE WIDE_ITEM_LIST type + * Note: Main item requires 2:1 ratio, sub items require 1:1 ratio + */ + public function testSendBmsWideItemList(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + // Upload main image (2:1 ratio) + $mainImageId = $this->messageService->uploadFile($imagePath, 'BMS_WIDE_ITEM_LIST_MAIN'); + // Upload sub images (1:1 ratio) + $subImageId = $this->messageService->uploadFile($imagePath, 'BMS_WIDE_ITEM_LIST_SUB'); + + $mainItem = new BmsMainWideItem(); + $mainItem->setImageId($mainImageId) + ->setLinkMobile('https://example.com/main'); + + $subItems = []; + for ($i = 1; $i <= 3; $i++) { + $subItem = new BmsSubWideItem(); + $subItem->setTitle("아이템 $i") + ->setImageId($subImageId) + ->setLinkMobile("https://example.com/item$i"); + $subItems[] = $subItem; + } + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::WIDE_ITEM_LIST) + ->setHeader('WIDE ITEM LIST 테스트') + ->setMainWideItem($mainItem) + ->setSubWideItemList($subItems); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE WIDE_ITEM_LIST test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE CAROUSEL_FEED type + */ + public function testSendBmsCarouselFeed(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + $imageId = $this->messageService->uploadFile($imagePath, 'BMS_CAROUSEL_FEED_LIST'); + + $items = []; + for ($i = 1; $i <= 2; $i++) { + $button = new BmsButton(); + $button->setLinkType('WL') + ->setName("버튼 $i") + ->setLinkMobile("https://example.com/item$i"); + + $item = new BmsCarouselFeedItem(); + $item->setHeader("캐러셀 피드 아이템 $i") + ->setContent("캐러셀 피드 아이템 $i 의 내용입니다.") + ->setImageId($imageId) + ->setButtons([$button]); + $items[] = $item; + } + + $tail = new BmsCarouselTail(); + $tail->setLinkMobile('https://example.com/more'); + + $carousel = new BmsCarousel(); + $carousel->setList($items) + ->setTail($tail); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::CAROUSEL_FEED) + ->setCarousel($carousel); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE CAROUSEL_FEED test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE CAROUSEL_COMMERCE type + */ + public function testSendBmsCarouselCommerce(): void + { + $imagePath = $this->getTestImagePath(); + if (!$imagePath) { + $this->markTestSkipped('Test image not found'); + } + + try { + $imageId = $this->messageService->uploadFile($imagePath, 'BMS_CAROUSEL_COMMERCE_LIST'); + + $items = []; + for ($i = 1; $i <= 2; $i++) { + $commerce = new BmsCommerce(); + $commerce->setTitle("상품 $i") + ->setRegularPrice(10000 * $i); + + $button = new BmsButton(); + $button->setLinkType('WL') + ->setName('구매하기') + ->setLinkMobile("https://example.com/product$i"); + + $item = new BmsCarouselCommerceItem(); + $item->setCommerce($commerce) + ->setImageId($imageId) + ->setButtons([$button]); + $items[] = $item; + } + + $tail = new BmsCarouselTail(); + $tail->setLinkMobile('https://example.com/more'); + + $carousel = new BmsCarousel(); + $carousel->setList($items) + ->setTail($tail); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::CAROUSEL_COMMERCE) + ->setCarousel($carousel); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE CAROUSEL_COMMERCE test skipped: ' . $e->getMessage()); + } + } + + /** + * Test sending BMS FREE PREMIUM_VIDEO type + * Note: Requires a valid Kakao TV video URL + */ + public function testSendBmsPremiumVideo(): void + { + try { + $video = new BmsVideo(); + $video->setVideoUrl('https://tv.kakao.com/v/123456789'); + + $bms = new KakaoBms(); + $bms->setTargeting('I') + ->setChatBubbleType(BmsChatBubbleType::PREMIUM_VIDEO) + ->setVideo($video) + ->setContent('[E2E 테스트] BMS FREE PREMIUM_VIDEO 테스트입니다.'); + + $kakaoOption = new KakaoOption(); + $kakaoOption->setPfId($this->pfId) + ->setBms($bms); + + $message = new Message(); + $message->setTo($this->recipientNumber) + ->setFrom($this->senderNumber) + ->setType('BMS_FREE') + ->setKakaoOptions($kakaoOption); + + $response = $this->messageService->send($message); + + $this->assertInstanceOf(SendResponse::class, $response); + $this->assertNotNull($response->groupInfo); + $this->assertGreaterThan(0, $response->groupInfo->count->total); + + echo sprintf( + "\nGroup ID: %s\nTotal: %d\nSuccess: %d\n", + $response->groupInfo->groupId, + $response->groupInfo->count->total, + $response->groupInfo->count->registeredSuccess + ); + } catch (Exception $e) { + $this->markTestSkipped('BMS FREE PREMIUM_VIDEO test skipped: ' . $e->getMessage()); + } + } + + /** + * Get test image path + */ + private function getTestImagePath(): ?string + { + if ($this->testImagePath && file_exists($this->testImagePath)) { + return $this->testImagePath; + } + + $possiblePaths = [ + __DIR__ . '/../../examples/images/example_square.jpg', + __DIR__ . '/../../examples/images/example_wide.jpg', + __DIR__ . '/../fixtures/test_image.jpg', + ]; + + foreach ($possiblePaths as $path) { + if (file_exists($path)) { + return $path; + } + } + + return null; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..729fd29 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,32 @@ +