diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json new file mode 100644 index 0000000..2fd5e1b --- /dev/null +++ b/.github/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "5.1.3" +} diff --git a/.github/release-please-config.json b/.github/release-please-config.json new file mode 100644 index 0000000..ddd592f --- /dev/null +++ b/.github/release-please-config.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "packages": { + ".": { + "release-type": "php", + "package-name": "solapi/sdk", + "include-component-in-tag": false, + "include-v-in-tag": true, + "tag-separator": "", + "draft": false, + "prerelease": false, + "changelog-path": "CHANGELOG.md", + "extra-files": [ + { + "type": "generic", + "path": "src/Models/Request/DefaultAgent.php" + } + ] + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3fbdbfd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + pull_request: + branches: [master] + push: + branches: [master] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + unit-test: + name: Unit / PHP ${{ matrix.php }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4", "8.5"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: json, mbstring + coverage: none + tools: composer:v2 + + - name: Determine PHPUnit constraint + id: phpunit + run: | + case "${{ matrix.php }}" in + 7.1|7.2) echo "constraint=^7.5" >> "$GITHUB_OUTPUT" ;; + *) echo "constraint=^9.5" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Resolve Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ matrix.php }}-${{ steps.phpunit.outputs.constraint }}-${{ hashFiles('composer.json') }} + restore-keys: | + composer-${{ matrix.php }}-${{ steps.phpunit.outputs.constraint }}- + composer-${{ matrix.php }}- + + - name: Allow legacy PHPUnit on PHP 7.1/7.2 + if: matrix.php == '7.1' || matrix.php == '7.2' + # PHP 7.1 ships an older Composer (<=2.2) that lacks the audit.block-insecure setting, + # so the command exits non-zero. That same older Composer also does not enforce the + # block, so ignoring the failure is safe. + run: composer config --no-plugins audit.block-insecure false || true + + - name: Pin PHPUnit constraint + run: composer require --dev --no-update --no-interaction "phpunit/phpunit:${{ steps.phpunit.outputs.constraint }}" + + - name: Install dependencies + env: + COMPOSER_NO_AUDIT: "1" + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run unit tests + run: composer test:unit diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..a46038c --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,89 @@ +name: E2E (manual) + +on: + workflow_dispatch: + inputs: + php-version: + description: "Run E2E tests on this PHP version" + required: false + default: "8.3" + type: choice + options: + - "7.1" + - "7.2" + - "7.3" + - "7.4" + - "8.0" + - "8.1" + - "8.2" + - "8.3" + - "8.4" + - "8.5" + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + e2e: + name: E2E / PHP ${{ inputs.php-version }} + runs-on: ubuntu-latest + environment: production + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ inputs.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ inputs.php-version }} + extensions: json, mbstring + coverage: none + tools: composer:v2 + + - name: Determine PHPUnit constraint + id: phpunit + run: | + case "${{ inputs.php-version }}" in + 7.1|7.2) echo "constraint=^7.5" >> "$GITHUB_OUTPUT" ;; + *) echo "constraint=^9.5" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Resolve Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-e2e-${{ inputs.php-version }}-${{ steps.phpunit.outputs.constraint }}-${{ hashFiles('composer.json') }} + restore-keys: | + composer-e2e-${{ inputs.php-version }}- + + - name: Allow legacy PHPUnit on PHP 7.1/7.2 + if: inputs.php-version == '7.1' || inputs.php-version == '7.2' + # PHP 7.1 ships an older Composer (<=2.2) that lacks the audit.block-insecure setting, + # so the command exits non-zero. That same older Composer also does not enforce the + # block, so ignoring the failure is safe. + run: composer config --no-plugins audit.block-insecure false || true + + - name: Pin PHPUnit constraint + run: composer require --dev --no-update --no-interaction "phpunit/phpunit:${{ steps.phpunit.outputs.constraint }}" + + - name: Install dependencies + env: + COMPOSER_NO_AUDIT: "1" + run: composer update --prefer-dist --no-interaction --no-progress + + - name: Run E2E tests + env: + SOLAPI_API_KEY: ${{ secrets.SOLAPI_API_KEY }} + SOLAPI_API_SECRET: ${{ secrets.SOLAPI_API_SECRET }} + SOLAPI_KAKAO_PF_ID: ${{ secrets.SOLAPI_KAKAO_PF_ID }} + SOLAPI_SENDER_NUMBER: ${{ secrets.SOLAPI_SENDER_NUMBER }} + SOLAPI_RECIPIENT_NUMBER: ${{ secrets.SOLAPI_RECIPIENT_NUMBER }} + run: composer test:e2e diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..58c0cd9 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: release-please + +on: + push: + branches: + - master + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + name: Run release-please + runs-on: ubuntu-latest + steps: + - name: Run release-please + uses: googleapis/release-please-action@v4 + with: + config-file: .github/release-please-config.json + manifest-file: .github/.release-please-manifest.json diff --git a/.gitignore b/.gitignore index 023a2fb..63fbdd5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ composer.phar .phpunit.cache/ # omc -.omc/ \ No newline at end of file +.omc/ + +# Claude Code +.claude/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4b5bd01..1b2cf7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,153 +1,66 @@ -# SOLAPI PHP SDK - -**Generated:** 2026-01-27 -**Commit:** a27d32f -**Branch:** master - -## OVERVIEW - -PHP SDK for SOLAPI messaging API (SMS, LMS, MMS, Kakao Alimtalk/BMS, Voice, Fax) targeting Korean telecom. PSR-18 HTTP client abstraction, PHP 7.1+. - -## STRUCTURE - -``` -solapi-php/ -├── src/ -│ ├── Services/ # Entry point (SolapiMessageService) -│ ├── Libraries/ # HTTP client, auth, utilities (4 files) -│ ├── Models/ -│ │ ├── Request/ # API request DTOs (7 files) -│ │ ├── Response/ # API response DTOs (17 files) -│ │ ├── Kakao/ # Kakao options (4 files) -│ │ │ └── Bms/ # Brand Message Service (14 files) ← See Bms/AGENTS.md -│ │ ├── Voice/ # Voice options (3 files) -│ │ └── Fax/ # Fax options (1 file) -│ └── Exceptions/ # Custom exceptions (5 files) -├── tests/ -│ ├── Models/ # Unit tests -│ └── E2E/ # Integration tests -├── composer.json # PSR-4: Nurigo\Solapi\ → src/ -└── phpunit.xml # Test configuration -``` - -## 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, PSR-18 client | -| Auth header | `Libraries/Authenticator.php` | HMAC-SHA256, static method | -| HTTP transport | `Libraries/HttpClient.php` | stream_context-based, PSR-18 compliant | -| Kakao Alimtalk | `Models/Kakao/KakaoOption.php` | pfId, templateId, buttons, variables | -| Kakao BMS | `Models/Kakao/KakaoBms.php` | Brand messages, 8 chatBubbleTypes | -| BMS validation | `Models/Kakao/Bms/BmsValidator.php` | Field requirements by type | -| Voice options | `Models/Voice/VoiceOption.php` | voiceType, headerMessage, tailMessage | -| Fax options | `Models/Fax/FaxOption.php` | fileIds array | -| Error handling | `Exceptions/` | BaseException, HttpException, BmsValidationException | -| Request DTOs | `Models/Request/` | SendRequest, GetMessagesRequest, etc. | -| Response DTOs | `Models/Response/` | SendResponse, GroupMessageResponse, etc. | - -## CODE MAP - -**Entry Point:** -```php -$service = new SolapiMessageService($apiKey, $apiSecret); -$response = $service->send($message); -``` - -**Call Flow:** -``` -SolapiMessageService - → Fetcher::getInstance() [singleton] - → Authenticator::getAuthorizationHeaderInfo() [static] - → NullEliminator::array_null_eliminate() [static] - → HttpClient::sendRequest() [PSR-18] - → stream_context + file_get_contents - → Response DTOs -``` - -**Key Classes:** -| Class | Type | Role | -|-------|------|------| -| `SolapiMessageService` | Service | Primary API (send, uploadFile, getMessages, getGroups, getBalance) | -| `Message` | Model | Fluent builder with 12 setters | -| `Fetcher` | Library | Singleton HTTP client, credential storage | -| `HttpClient` | Library | PSR-18 stream-based implementation | -| `Authenticator` | Library | HMAC-SHA256 auth header generation | -| `NullEliminator` | Library | Recursive null removal for JSON | -| `BmsValidator` | Validator | BMS field validation by chatBubbleType | - -**Model Hierarchy:** -``` -BaseMessage → Message (fluent builder) -BaseKakaoOption → KakaoOption (fluent builder) - └── KakaoBms (fluent builder, 8 types) - └── Bms/* components (14 files) -``` - -## CONVENTIONS - -**Namespace:** `Nurigo\Solapi\*` (PSR-4 from `src/`) - -**Patterns:** -- Fluent builder: `$msg->setTo("...")->setFrom("...")->setText("...")` -- Singleton: `Fetcher::getInstance($key, $secret)` -- Public properties with getter/setter pairs on models -- Korean PHPDoc comments (수신번호, 발신번호, 메시지 내용) - -**Type Safety:** -- Full type hints on method params/returns -- PHPDoc `@var`, `@param`, `@return`, `@throws` annotations -- PHP 7.1 compatible (no union types, no enums) - -**Enum-Like Constants:** -- `VoiceType::FEMALE`, `VoiceType::MALE` -- `BmsChatBubbleType::TEXT`, `IMAGE`, `WIDE`, etc. -- All have `values()` static method - -**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 - -## ANTI-PATTERNS - -- **Silent null returns:** get* methods return `null` on any exception — always check response validity -- **Singleton state:** Fetcher retains credentials — don't mix API keys in same process -- **No interfaces:** Service/Fetcher have no contracts — mocking requires concrete class extension -- **Hardcoded timezone:** `Asia/Seoul` set in Authenticator — affects global timezone -- **Hardcoded country:** `"82"` default in BaseMessage — Korean-only by default - -## UNIQUE STYLES - -- **Korean comments:** PHPDoc descriptions in Korean -- **PSR-18 via stream:** Uses `file_get_contents` + `stream_context_create`, not cURL -- **Null elimination:** Removes nulls before JSON serialization - -## COMMANDS - -```bash -# Install -composer require solapi/sdk - -# Run all tests -composer test - -# Run unit tests only -composer test:unit - -# Run E2E tests only -composer test:e2e - -# Run with coverage -composer test:coverage -``` - -## NOTES - -- **Examples:** External repo at `github.com/solapi/solapi-php-examples` -- **API docs:** `developers.solapi.com` -- **PHP requirement:** 7.1+ (ext-json, allow_url_fopen or custom PSR-18 client) -- **Dependencies:** psr/http-client, psr/http-message, nyholm/psr7 -- **BMS details:** See `src/Models/Kakao/Bms/AGENTS.md` for Brand Message Service specifics +# Instructions + +## Read First +- Read the code before writing tests. +- Treat code as the source of truth. +- Do not assume behavior. + +## Required +- Test both success and failure paths. +- Test all branches of conditions. +- Test boundary values (nil, empty, zero, min, max). +- Add regression tests for every bug fix. +- Validate invariants, not just errors. +- Ensure tests are deterministic. +- Ensure all resources are cleaned up. +- Use multiple test layers when needed (unit, boundary, fault, concurrency, fuzz). + +## Must Verify +- state consistency +- side effects +- idempotency +- rollback correctness +- resource cleanup + +## Failure Injection +- Simulate dependency failures. +- Cover first-call, Nth-call, and continuous failures. +- Include timeout and cancellation cases. +- Include partial success followed by failure. + +## Concurrency +- Verify no race conditions. +- Verify no deadlocks. +- Verify no duplicate execution. +- Verify ordering and invariants. + +## Persistence +- Ensure atomic behavior (all or nothing). +- Ensure no corrupt intermediate state. +- Ensure safe retry and recovery. + +## Fuzz +- Apply fuzz tests to input parsing and decoding. +- Ensure no panic or unbounded resource usage. + +## Determinism +- Do not rely on sleep-based timing. +- Control time and randomness. +- Use bounded retries. + +## Style +- Use table-driven tests where appropriate. +- Use fakes for external dependencies. +- Use cleanup hooks. + +## Naming +- Name tests by behavior, edge case, or failure mode. + +## Forbidden +- Do not test only happy paths. +- Do not skip edge cases. +- Do not write non-deterministic tests. +- Do not leave resources unverified. +- Do not merge multiple concerns into one test. +- Do not rely only on line coverage. +- Do not ignore failure scenarios. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7c87023 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +## [5.1.3](https://github.com/solapi/solapi-php/compare/v5.1.2...v5.1.3) (2026-05-11) + + +### Bug Fixes + +* getBalance 등 응답 메소드 TypeError 해결 및 매핑 정리 ([#26](https://github.com/solapi/solapi-php/issues/26)) ([5c02761](https://github.com/solapi/solapi-php/commit/5c0276104f5363d95e00d1169a13b247ba89a8b6)) +* harden response mapping edge cases ([aff1a43](https://github.com/solapi/solapi-php/commit/aff1a43bb155047b992ce374c51047aacac67f69)) +* 응답 매핑 TypeError 수정 및 CI·테스트 인프라 정비 ([af1fdaf](https://github.com/solapi/solapi-php/commit/af1fdafc890a83d54c47e145ae92da5164fc5281)) +* 응답 메소드 TypeError 해결 및 nested 객체 매핑 구조 정리 ([#26](https://github.com/solapi/solapi-php/issues/26)) ([1a91baa](https://github.com/solapi/solapi-php/commit/1a91baabbf1e801e508b1635ea0a78667b12de41)) + + +### Miscellaneous Chores + +* .claude/ 디렉토리를 .gitignore에 추가 ([bad8199](https://github.com/solapi/solapi-php/commit/bad8199bd07dae77636c0029809c6ba5153a6ca9)) +* **deps:** phpunit·sebastian/comparator 패치 버전 업데이트 ([5e6159b](https://github.com/solapi/solapi-php/commit/5e6159b3612d2b98e7c4383348c0abcd3509500e)) +* gitignore·의존성·테스트 문서 정리 ([fe6bae9](https://github.com/solapi/solapi-php/commit/fe6bae9c467a948110fd3f177096a349a0ecdcff)) diff --git a/CLAUDE.md b/CLAUDE.md index 5ea19f2..1b2cf7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,114 +1,66 @@ -# 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 messaging SDK for Korean telecommunications (SMS, LMS, MMS, Kakao Alimtalk, Kakao BMS, Voice, Fax). Version 5.1.0, requires PHP 7.1+ with json extension and `allow_url_fopen` enabled (or a custom PSR-18 HTTP client). - -**Dependencies:** PSR HTTP interfaces (psr/http-client, psr/http-message) + nyholm/psr7 - -## Commands - -```bash -# Install dependencies -composer install - -# Run all tests -composer test - -# Unit tests only (no API calls required) -composer test:unit - -# E2E tests (requires environment variables - see below) -composer test:e2e - -# Run with coverage report -composer test:coverage - -# Run a single test -./vendor/bin/phpunit --filter testName -``` - -### E2E Test Environment Variables - -```bash -SOLAPI_API_KEY=xxx -SOLAPI_API_SECRET=xxx -SOLAPI_KAKAO_PF_ID=xxx -SOLAPI_SENDER_NUMBER=01012345678 -SOLAPI_RECIPIENT_NUMBER=01087654321 -``` - -## Architecture - -**Entry Point:** `SolapiMessageService` in `src/Services/` - all public API methods - -**Call Flow:** -``` -SolapiMessageService → Fetcher (singleton) → Authenticator (static) → HttpClient (stream_context) → 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 orchestrator -- `HttpClient` (`Libraries/HttpClient.php`) - PSR-18 implementation using `stream_context` + `file_get_contents` -- `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/Kakao/Bms/` - Kakao Brand Message Service (14 files, 8 chatBubbleTypes) - see `src/Models/Kakao/Bms/AGENTS.md` -- `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 - -## 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 -- **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 -- **PSR-18 HTTP Client:** Default HttpClient uses `stream_context` + `file_get_contents`. A custom PSR-18 client can be injected if needed (e.g., for cURL or Guzzle) -- **SSL Verification:** Enabled by default in HttpClient; can be disabled via constructor options - -## External Resources - -- API Documentation: https://developers.solapi.com -- Examples Repository: https://github.com/solapi/solapi-php-examples +# Instructions + +## Read First +- Read the code before writing tests. +- Treat code as the source of truth. +- Do not assume behavior. + +## Required +- Test both success and failure paths. +- Test all branches of conditions. +- Test boundary values (nil, empty, zero, min, max). +- Add regression tests for every bug fix. +- Validate invariants, not just errors. +- Ensure tests are deterministic. +- Ensure all resources are cleaned up. +- Use multiple test layers when needed (unit, boundary, fault, concurrency, fuzz). + +## Must Verify +- state consistency +- side effects +- idempotency +- rollback correctness +- resource cleanup + +## Failure Injection +- Simulate dependency failures. +- Cover first-call, Nth-call, and continuous failures. +- Include timeout and cancellation cases. +- Include partial success followed by failure. + +## Concurrency +- Verify no race conditions. +- Verify no deadlocks. +- Verify no duplicate execution. +- Verify ordering and invariants. + +## Persistence +- Ensure atomic behavior (all or nothing). +- Ensure no corrupt intermediate state. +- Ensure safe retry and recovery. + +## Fuzz +- Apply fuzz tests to input parsing and decoding. +- Ensure no panic or unbounded resource usage. + +## Determinism +- Do not rely on sleep-based timing. +- Control time and randomness. +- Use bounded retries. + +## Style +- Use table-driven tests where appropriate. +- Use fakes for external dependencies. +- Use cleanup hooks. + +## Naming +- Name tests by behavior, edge case, or failure mode. + +## Forbidden +- Do not test only happy paths. +- Do not skip edge cases. +- Do not write non-deterministic tests. +- Do not leave resources unverified. +- Do not merge multiple concerns into one test. +- Do not rely only on line coverage. +- Do not ignore failure scenarios. diff --git a/composer.json b/composer.json index 55772dd..b7677ba 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "solapi/sdk", "description": "SOLAPI SDK for PHP", - "version": "5.1.2", + "version": "5.1.3", "type": "library", "license": "MIT", "autoload": { diff --git a/composer.lock b/composer.lock index 30fc440..39a60e6 100644 --- a/composer.lock +++ b/composer.lock @@ -872,16 +872,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.31", + "version": "9.6.34", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "945d0b7f346a084ce5549e95289962972c4272e5" + "reference": "b36f02317466907a230d3aa1d34467041271ef4a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5", - "reference": "945d0b7f346a084ce5549e95289962972c4272e5", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/b36f02317466907a230d3aa1d34467041271ef4a", + "reference": "b36f02317466907a230d3aa1d34467041271ef4a", "shasum": "" }, "require": { @@ -903,7 +903,7 @@ "phpunit/php-timer": "^5.0.3", "sebastian/cli-parser": "^1.0.2", "sebastian/code-unit": "^1.0.8", - "sebastian/comparator": "^4.0.9", + "sebastian/comparator": "^4.0.10", "sebastian/diff": "^4.0.6", "sebastian/environment": "^5.1.5", "sebastian/exporter": "^4.0.8", @@ -955,7 +955,7 @@ "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" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.34" }, "funding": [ { @@ -979,7 +979,7 @@ "type": "tidelift" } ], - "time": "2025-12-06T07:45:52+00:00" + "time": "2026-01-27T05:45:00+00:00" }, { "name": "sebastian/cli-parser", @@ -1150,16 +1150,16 @@ }, { "name": "sebastian/comparator", - "version": "4.0.9", + "version": "4.0.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5" + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5", - "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/e4df00b9b3571187db2831ae9aada2c6efbd715d", + "reference": "e4df00b9b3571187db2831ae9aada2c6efbd715d", "shasum": "" }, "require": { @@ -1212,7 +1212,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", - "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9" + "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.10" }, "funding": [ { @@ -1232,7 +1232,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T06:51:50+00:00" + "time": "2026-01-24T09:22:56+00:00" }, { "name": "sebastian/complexity", diff --git a/phpunit.xml b/phpunit.xml index 6ac9d55..6e07c4e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,6 +7,7 @@ tests/Models + tests/Services tests/E2E diff --git a/src/Exceptions/BaseException.php b/src/Exceptions/BaseException.php index e488a17..9752c93 100644 --- a/src/Exceptions/BaseException.php +++ b/src/Exceptions/BaseException.php @@ -14,7 +14,7 @@ class BaseException extends Exception public function __construct($message = "", $errorCode = "") { - parent::__construct($message); - $this->errorCode = $errorCode; + parent::__construct($message ?? ""); + $this->errorCode = $errorCode ?? ""; } -} \ No newline at end of file +} diff --git a/src/Libraries/Fetcher.php b/src/Libraries/Fetcher.php index 023e0b3..6969a6c 100644 --- a/src/Libraries/Fetcher.php +++ b/src/Libraries/Fetcher.php @@ -47,6 +47,17 @@ public function __destruct() $this->apiSecretKey = ''; } + /** + * Clears the singleton instance. + * + * @internal Intended only for test isolation so each test can inject a + * fresh PSR-18 client. Do not call from production code. + */ + public static function resetForTesting(): void + { + self::$singleton = null; + } + /** * @param string $method * @param string $uri diff --git a/src/Libraries/ResponseMapper.php b/src/Libraries/ResponseMapper.php new file mode 100644 index 0000000..a1929e0 --- /dev/null +++ b/src/Libraries/ResponseMapper.php @@ -0,0 +1,85 @@ + $class + * @return T|null + */ + public static function mapObject($value, string $class) + { + if ($value === null) { + return null; + } + if (is_array($value)) { + $value = (object) $value; + } + if (!is_object($value)) { + return null; + } + + return new $class($value); + } + + /** + * Convert a JSON array or object-keyed list into a plain indexed array. + * + * @param mixed $value + * @return array|null + */ + public static function normalizeList($value): ?array + { + if ($value === null) { + return null; + } + if (is_array($value)) { + $items = $value; + } else if (is_object($value)) { + $items = get_object_vars($value); + } else { + return null; + } + + return array_values($items); + } + + /** + * Convert a list-like value into an array of $class instances. + * + * Accepts both JSON arrays and JSON objects (SOLAPI sometimes keys lists + * by resource id). Returns null when the value is absent so callers can + * distinguish "missing" from "empty". + * + * @template T of object + * @param mixed $value + * @param class-string $class + * @return T[]|null + */ + public static function mapList($value, string $class): ?array + { + $items = self::normalizeList($value); + if ($items === null) { + return null; + } + + $mapped = array_map(static function ($item) use ($class) { + return self::mapObject($item, $class); + }, $items); + + return array_values(array_filter($mapped, static function ($item) { + return $item !== null; + })); + } +} diff --git a/src/Models/Request/DefaultAgent.php b/src/Models/Request/DefaultAgent.php index 69f7871..b79ee73 100644 --- a/src/Models/Request/DefaultAgent.php +++ b/src/Models/Request/DefaultAgent.php @@ -16,7 +16,7 @@ class DefaultAgent public function __construct() { - $this->sdkVersion = 'php/5.1.2'; + $this->sdkVersion = 'php/5.1.3'; // x-release-please-version $this->osPlatform = PHP_OS . " | " . phpversion(); } } diff --git a/src/Models/Response/CommonCashResponse.php b/src/Models/Response/CommonCashResponse.php index 1125d5c..1e9bb9a 100644 --- a/src/Models/Response/CommonCashResponse.php +++ b/src/Models/Response/CommonCashResponse.php @@ -5,22 +5,33 @@ class CommonCashResponse { /** - * @var int + * @var int|null */ public $requested; /** - * @var int + * @var int|null */ public $replacement; /** - * @var int + * @var int|null */ public $refund; /** - * @var int + * @var int|null */ public $sum; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->requested = $value->requested ?? null; + $this->replacement = $value->replacement ?? null; + $this->refund = $value->refund ?? null; + $this->sum = $value->sum ?? null; + } +} diff --git a/src/Models/Response/ErrorResponse.php b/src/Models/Response/ErrorResponse.php index ae65ab3..21777b1 100644 --- a/src/Models/Response/ErrorResponse.php +++ b/src/Models/Response/ErrorResponse.php @@ -7,22 +7,22 @@ class ErrorResponse implements JsonSerializable { /** - * @var string + * @var string|null */ public $errorCode; /** - * @var string + * @var string|null */ public $errorMessage; /** - * @param $value mixed + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { - $this->errorCode = $value->errorCode; - $this->errorMessage = $value->errorMessage; + $this->errorCode = $value->errorCode ?? null; + $this->errorMessage = $value->errorMessage ?? null; } public function jsonSerialize(): array @@ -32,4 +32,4 @@ public function jsonSerialize(): array "errorMessage" => $this->errorMessage ]; } -} \ No newline at end of file +} diff --git a/src/Models/Response/FailedMessage.php b/src/Models/Response/FailedMessage.php index 888f9a1..9467a0d 100644 --- a/src/Models/Response/FailedMessage.php +++ b/src/Models/Response/FailedMessage.php @@ -5,42 +5,57 @@ class FailedMessage { /** - * @var string + * @var string|null */ public $to; /** - * @var string + * @var string|null */ public $from; /** - * @var string + * @var string|null */ public $type; /** - * @var string + * @var string|null */ public $statusMessage; /** - * @var string + * @var string|null */ public $country; /** - * @var string + * @var string|null */ public $messageId; /** - * @var string + * @var string|null */ public $statusCode; /** - * @var string + * @var string|null */ public $accountId; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->to = $value->to ?? null; + $this->from = $value->from ?? null; + $this->type = $value->type ?? null; + $this->statusMessage = $value->statusMessage ?? null; + $this->country = $value->country ?? null; + $this->messageId = $value->messageId ?? null; + $this->statusCode = $value->statusCode ?? null; + $this->accountId = $value->accountId ?? null; + } +} diff --git a/src/Models/Response/GetBalanceResponse.php b/src/Models/Response/GetBalanceResponse.php index 7ace1f9..0b2d052 100644 --- a/src/Models/Response/GetBalanceResponse.php +++ b/src/Models/Response/GetBalanceResponse.php @@ -5,12 +5,21 @@ class GetBalanceResponse { /** - * @var float + * @var float|null */ public $point; /** - * @var float + * @var float|null */ public $balance; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->balance = $value->balance ?? null; + $this->point = $value->point ?? null; + } +} diff --git a/src/Models/Response/GetGroupMessagesResponse.php b/src/Models/Response/GetGroupMessagesResponse.php index ffbd0bc..06a8344 100644 --- a/src/Models/Response/GetGroupMessagesResponse.php +++ b/src/Models/Response/GetGroupMessagesResponse.php @@ -2,38 +2,38 @@ namespace Nurigo\Solapi\Models\Response; -use Nurigo\Solapi\Models\BaseMessage; +use Nurigo\Solapi\Libraries\ResponseMapper; class GetGroupMessagesResponse { /** - * @var string + * @var string|null */ public $startKey; /** - * @var string + * @var string|null */ public $nextKey; /** - * @var int + * @var int|null */ public $limit; /** - * @var BaseMessage[] + * @var object[]|null */ public $messageList; /** - * @param mixed $value + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { $this->limit = $value->limit ?? null; - $this->messageList = $value->messageList ?? null; + $this->messageList = ResponseMapper::normalizeList($value->messageList ?? null); $this->startKey = $value->startKey ?? null; $this->nextKey = $value->nextKey ?? null; } -} \ No newline at end of file +} diff --git a/src/Models/Response/GetGroupsResponse.php b/src/Models/Response/GetGroupsResponse.php index c2c2cd1..8eb59e3 100644 --- a/src/Models/Response/GetGroupsResponse.php +++ b/src/Models/Response/GetGroupsResponse.php @@ -2,36 +2,38 @@ namespace Nurigo\Solapi\Models\Response; +use Nurigo\Solapi\Libraries\ResponseMapper; + class GetGroupsResponse { /** - * @var string + * @var string|null */ public $startKey; /** - * @var string + * @var string|null */ public $nextKey; /** - * @var int + * @var int|null */ public $limit; /** - * @var GroupMessageResponse[] + * @var GroupMessageResponse[]|null */ public $groupList; /** - * @param mixed $value + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { $this->limit = $value->limit ?? null; - $this->groupList = $value->groupList ?? null; $this->startKey = $value->startKey ?? null; $this->nextKey = $value->nextKey ?? null; + $this->groupList = ResponseMapper::mapList($value->groupList ?? null, GroupMessageResponse::class); } -} \ No newline at end of file +} diff --git a/src/Models/Response/GetMessagesResponse.php b/src/Models/Response/GetMessagesResponse.php index 923f9af..8e2f423 100644 --- a/src/Models/Response/GetMessagesResponse.php +++ b/src/Models/Response/GetMessagesResponse.php @@ -2,40 +2,39 @@ namespace Nurigo\Solapi\Models\Response; -use Nurigo\Solapi\Models\BaseMessage; -use Nurigo\Solapi\Models\Message; +use Nurigo\Solapi\Libraries\ResponseMapper; class GetMessagesResponse { /** - * @var int + * @var int|null */ public $limit; /** - * @var BaseMessage[] + * @var object[]|null */ public $messageList; /** - * @var string + * @var string|null */ public $startKey; /** - * @var string + * @var string|null */ public $nextKey; /** - * @param mixed $value + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { $this->limit = $value->limit ?? null; - $this->messageList = $value->messageList ?? null; + $this->messageList = ResponseMapper::normalizeList($value->messageList ?? null); $this->startKey = $value->startKey ?? null; $this->nextKey = $value->nextKey ?? null; } -} \ No newline at end of file +} diff --git a/src/Models/Response/GetStatisticsRequest.php b/src/Models/Response/GetStatisticsRequest.php index bdb57d8..5be583e 100644 --- a/src/Models/Response/GetStatisticsRequest.php +++ b/src/Models/Response/GetStatisticsRequest.php @@ -5,17 +5,17 @@ class GetStatisticsRequest { /** - * @var string + * @var string|null */ public $startDate; /** - * @var string + * @var string|null */ public $endDate; /** - * @var string + * @var string|null */ public $masterAccountId; @@ -72,4 +72,4 @@ public function setMasterAccountId(string $masterAccountId): GetStatisticsReques $this->masterAccountId = $masterAccountId; return $this; } -} \ No newline at end of file +} diff --git a/src/Models/Response/GetStatisticsResponse.php b/src/Models/Response/GetStatisticsResponse.php index 5850f91..8d385b6 100644 --- a/src/Models/Response/GetStatisticsResponse.php +++ b/src/Models/Response/GetStatisticsResponse.php @@ -2,37 +2,50 @@ namespace Nurigo\Solapi\Models\Response; +use Nurigo\Solapi\Libraries\ResponseMapper; + class GetStatisticsResponse { /** - * @var int + * @var int|null */ public $balance; /** - * @var int + * @var int|null */ public $point; /** - * @var int + * @var int|null */ public $monthlyBalanceAvg; /** - * @var int + * @var int|null */ public $monthlyPointAvg; /** - * @var StatisticsMonthPeriod[] + * @var StatisticsMonthPeriod[]|null */ public $monthPeriod; /** - * @var MessageType + * @var MessageType|null */ public $total; - -} \ No newline at end of file + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->balance = $value->balance ?? null; + $this->point = $value->point ?? null; + $this->monthlyBalanceAvg = $value->monthlyBalanceAvg ?? null; + $this->monthlyPointAvg = $value->monthlyPointAvg ?? null; + $this->monthPeriod = ResponseMapper::mapList($value->monthPeriod ?? null, StatisticsMonthPeriod::class); + $this->total = ResponseMapper::mapObject($value->total ?? null, MessageType::class); + } +} diff --git a/src/Models/Response/GroupCount.php b/src/Models/Response/GroupCount.php index 88f1940..6d17cca 100644 --- a/src/Models/Response/GroupCount.php +++ b/src/Models/Response/GroupCount.php @@ -5,47 +5,63 @@ class GroupCount { /** - * @var int + * @var int|null */ public $total; /** - * @var int + * @var int|null */ public $sendTotal; /** - * @var int + * @var int|null */ public $sentFailed; /** - * @var int + * @var int|null */ public $sentSuccess; /** - * @var int + * @var int|null */ public $sentPending; /** - * @var int + * @var int|null */ public $sentReplacement; /** - * @var int + * @var int|null */ public $refund; /** - * @var int + * @var int|null */ public $registeredFailed; /** - * @var int + * @var int|null */ public $registeredSuccess; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->total = $value->total ?? null; + $this->sendTotal = $value->sendTotal ?? null; + $this->sentFailed = $value->sentFailed ?? null; + $this->sentSuccess = $value->sentSuccess ?? null; + $this->sentPending = $value->sentPending ?? null; + $this->sentReplacement = $value->sentReplacement ?? null; + $this->refund = $value->refund ?? null; + $this->registeredFailed = $value->registeredFailed ?? null; + $this->registeredSuccess = $value->registeredSuccess ?? null; + } +} diff --git a/src/Models/Response/GroupCountForCharge.php b/src/Models/Response/GroupCountForCharge.php index 5ba79f2..bd3bffc 100644 --- a/src/Models/Response/GroupCountForCharge.php +++ b/src/Models/Response/GroupCountForCharge.php @@ -6,57 +6,75 @@ class GroupCountForCharge { /** - * @var object + * @var object|null */ public $sms; /** - * @var object + * @var object|null */ public $lms; /** - * @var object + * @var object|null */ public $mms; /** - * @var object + * @var object|null */ public $ata; /** - * @var object + * @var object|null */ public $cta; /** - * @var object + * @var object|null */ public $cti; /** - * @var object; + * @var object|null */ public $nsa; /** - * @var object + * @var object|null */ public $rcs_sms; /** - * @var object + * @var object|null */ public $rcs_lms; /** - * @var object + * @var object|null */ public $rcs_mms; /** - * @var object + * @var object|null */ public $rcs_tpl; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->sms = $value->sms ?? null; + $this->lms = $value->lms ?? null; + $this->mms = $value->mms ?? null; + $this->ata = $value->ata ?? null; + $this->cta = $value->cta ?? null; + $this->cti = $value->cti ?? null; + $this->nsa = $value->nsa ?? null; + $this->rcs_sms = $value->rcs_sms ?? null; + $this->rcs_lms = $value->rcs_lms ?? null; + $this->rcs_mms = $value->rcs_mms ?? null; + $this->rcs_tpl = $value->rcs_tpl ?? null; + } +} diff --git a/src/Models/Response/GroupMessageResponse.php b/src/Models/Response/GroupMessageResponse.php index 0d20b40..62efd3b 100644 --- a/src/Models/Response/GroupMessageResponse.php +++ b/src/Models/Response/GroupMessageResponse.php @@ -2,58 +2,58 @@ namespace Nurigo\Solapi\Models\Response; -use stdClass; +use Nurigo\Solapi\Libraries\ResponseMapper; class GroupMessageResponse { /** - * @var GroupCount + * @var GroupCount|null */ public $count; /** - * @var GroupCountForCharge + * @var GroupCountForCharge|null */ public $countForCharge; /** - * @var CommonCashResponse + * @var CommonCashResponse|null */ public $balance; /** - * @var CommonCashResponse + * @var CommonCashResponse|null */ public $point; /** - * @var object + * @var object|null */ public $app; /** - * @var object + * @var object|null */ public $log; /** - * @var string 메시지 그룹 상태 + * @var string|null 메시지 그룹 상태 */ public $status; /** - * @var bool 중복 수신번호 허용 여부 + * @var bool|null 중복 수신번호 허용 여부 * true로 설정하면 중복 수신번호를 허용함 */ public $allowDuplicates; /** - * @var bool + * @var bool|null */ public $isRefunded; /** - * @var string 계정 고유번호 + * @var string|null 계정 고유번호 */ public $accountId; @@ -63,37 +63,62 @@ class GroupMessageResponse public $masterAccountId; /** - * @var string 메시지 그룹 ID + * @var string|null 메시지 그룹 ID */ public $groupId; /** - * @var array + * @var array|null */ public $price; /** - * @var string 메시지 그룹 생성일시 + * @var string|null 메시지 그룹 생성일시 */ public $dateCreated; /** - * @var string 메시지 그룹 수정일시 + * @var string|null 메시지 그룹 수정일시 */ public $dateUpdated; /** - * @var string 메시지 그룹 예약일시 + * @var string|null 메시지 그룹 예약일시 */ public $scheduledDate; /** - * @var string 메시지 그룹 발송일시 + * @var string|null 메시지 그룹 발송일시 */ public $dateSent; /** - * @var string 메시지 그룹 발송 완료일시 + * @var string|null 메시지 그룹 발송 완료일시 */ public $dateCompleted; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->count = ResponseMapper::mapObject($value->count ?? null, GroupCount::class); + $this->countForCharge = ResponseMapper::mapObject($value->countForCharge ?? null, GroupCountForCharge::class); + $this->balance = ResponseMapper::mapObject($value->balance ?? null, CommonCashResponse::class); + $this->point = ResponseMapper::mapObject($value->point ?? null, CommonCashResponse::class); + $this->app = $value->app ?? null; + $this->log = $value->log ?? null; + $this->status = $value->status ?? null; + $this->allowDuplicates = $value->allowDuplicates ?? null; + $this->isRefunded = $value->isRefunded ?? null; + $this->accountId = $value->accountId ?? null; + $this->masterAccountId = $value->masterAccountId ?? null; + $this->groupId = $value->groupId ?? null; + $this->price = $value->price ?? null; + $this->dateCreated = $value->dateCreated ?? null; + $this->dateUpdated = $value->dateUpdated ?? null; + $this->scheduledDate = $value->scheduledDate ?? null; + $this->dateSent = $value->dateSent ?? null; + $this->dateCompleted = $value->dateCompleted ?? null; + } +} diff --git a/src/Models/Response/MessageType.php b/src/Models/Response/MessageType.php index 5b23624..d28826d 100644 --- a/src/Models/Response/MessageType.php +++ b/src/Models/Response/MessageType.php @@ -5,62 +5,81 @@ class MessageType { /** - * @var int + * @var int|null */ public $total; /** - * @var int + * @var int|null */ public $sms; /** - * @var int + * @var int|null */ public $lms; /** - * @var int + * @var int|null */ public $mms; /** - * @var int + * @var int|null */ public $ata; /** - * @var int + * @var int|null */ public $cta; /** - * @var int + * @var int|null */ public $cti; /** - * @var int + * @var int|null */ public $nsa; /** - * @var int + * @var int|null */ public $rcs_sms; /** - * @var int + * @var int|null */ public $rcs_lms; /** - * @var int + * @var int|null */ public $rcs_mms; /** - * @var int + * @var int|null */ public $rcs_tpl; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->total = $value->total ?? null; + $this->sms = $value->sms ?? null; + $this->lms = $value->lms ?? null; + $this->mms = $value->mms ?? null; + $this->ata = $value->ata ?? null; + $this->cta = $value->cta ?? null; + $this->cti = $value->cti ?? null; + $this->nsa = $value->nsa ?? null; + $this->rcs_sms = $value->rcs_sms ?? null; + $this->rcs_lms = $value->rcs_lms ?? null; + $this->rcs_mms = $value->rcs_mms ?? null; + $this->rcs_tpl = $value->rcs_tpl ?? null; + } +} diff --git a/src/Models/Response/SendResponse.php b/src/Models/Response/SendResponse.php index 0dd9645..4f0452f 100644 --- a/src/Models/Response/SendResponse.php +++ b/src/Models/Response/SendResponse.php @@ -3,26 +3,27 @@ namespace Nurigo\Solapi\Models\Response; use JsonSerializable; +use Nurigo\Solapi\Libraries\ResponseMapper; class SendResponse implements JsonSerializable { /** - * @var GroupMessageResponse + * @var GroupMessageResponse|null */ public $groupInfo; /** - * @var FailedMessage[] + * @var FailedMessage[]|null */ public $failedMessageList; /** - * @param $value mixed + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { - $this->groupInfo = $value->groupInfo; - $this->failedMessageList = $value->failedMessageList; + $this->groupInfo = ResponseMapper::mapObject($value->groupInfo ?? null, GroupMessageResponse::class); + $this->failedMessageList = ResponseMapper::mapList($value->failedMessageList ?? null, FailedMessage::class) ?? []; } /** @@ -35,4 +36,4 @@ public function jsonSerialize(): array "failedMessageList" => $this->failedMessageList ]; } -} \ No newline at end of file +} diff --git a/src/Models/Response/StatisticsDayPeriod.php b/src/Models/Response/StatisticsDayPeriod.php index cc884a0..a5eb81f 100644 --- a/src/Models/Response/StatisticsDayPeriod.php +++ b/src/Models/Response/StatisticsDayPeriod.php @@ -2,40 +2,56 @@ namespace Nurigo\Solapi\Models\Response; +use Nurigo\Solapi\Libraries\ResponseMapper; + class StatisticsDayPeriod { /** - * @var string + * @var string|null */ public $month; /** - * @var int + * @var int|null */ public $balance; /** - * @var MessageType[] + * @var MessageType[]|null */ public $statusCode; /** - * @var object + * @var object|null */ public $refund; /** - * @var MessageType + * @var MessageType|null */ public $total; /** - * @var MessageType + * @var MessageType|null */ public $successed; /** - * @var MessageType + * @var MessageType|null */ public $failed; + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->month = $value->month ?? null; + $this->balance = $value->balance ?? null; + $this->refund = $value->refund ?? null; + $this->statusCode = ResponseMapper::mapList($value->statusCode ?? null, MessageType::class); + $this->total = ResponseMapper::mapObject($value->total ?? null, MessageType::class); + $this->successed = ResponseMapper::mapObject($value->successed ?? null, MessageType::class); + $this->failed = ResponseMapper::mapObject($value->failed ?? null, MessageType::class); + } } diff --git a/src/Models/Response/StatisticsMonthPeriod.php b/src/Models/Response/StatisticsMonthPeriod.php index 6a2deb0..38b0eda 100644 --- a/src/Models/Response/StatisticsMonthPeriod.php +++ b/src/Models/Response/StatisticsMonthPeriod.php @@ -2,55 +2,74 @@ namespace Nurigo\Solapi\Models\Response; +use Nurigo\Solapi\Libraries\ResponseMapper; + class StatisticsMonthPeriod { /** - * @var string + * @var string|null */ public $date; /** - * @var int + * @var int|null */ public $balance; /** - * @var int + * @var int|null */ public $balanceAvg; /** - * @var int + * @var int|null */ public $point; /** - * @var int + * @var int|null */ public $pointAvg; /** - * @var StatisticsDayPeriod[] + * @var StatisticsDayPeriod[]|null */ public $dayPeriod; /** - * @var object[] + * @var object[]|null */ public $refund; /** - * @var object + * @var object|null */ public $total; /** - * @var object + * @var object|null */ public $successed; /** - * @var object + * @var object|null */ public $failed; -} \ No newline at end of file + + /** + * @param \stdClass|null $value + */ + public function __construct($value = null) + { + $this->date = $value->date ?? null; + $this->balance = $value->balance ?? null; + $this->balanceAvg = $value->balanceAvg ?? null; + $this->point = $value->point ?? null; + $this->pointAvg = $value->pointAvg ?? null; + $this->refund = $value->refund ?? null; + $this->total = $value->total ?? null; + $this->successed = $value->successed ?? null; + $this->failed = $value->failed ?? null; + $this->dayPeriod = ResponseMapper::mapList($value->dayPeriod ?? null, StatisticsDayPeriod::class); + } +} diff --git a/src/Models/Response/UploadFileResponse.php b/src/Models/Response/UploadFileResponse.php index a29dcca..de86676 100644 --- a/src/Models/Response/UploadFileResponse.php +++ b/src/Models/Response/UploadFileResponse.php @@ -5,63 +5,63 @@ class UploadFileResponse { /** - * @var string + * @var string|null */ public $type; /** - * @var string + * @var string|null */ public $originalName; /** - * @var string + * @var string|null */ public $link; /** - * @var string + * @var string|null */ public $fileId; /** - * @var string + * @var string|null */ public $name; /** - * @var string + * @var string|null */ public $url; /** - * @var string + * @var string|null */ public $accountId; /** - * @var string + * @var string|null */ public $dateCreated; /** - * @var string + * @var string|null */ public $dateUpdated; /** - * @param mixed $value + * @param \stdClass|null $value */ - public function __construct($value) + public function __construct($value = null) { - $this->type = $value->type; - $this->originalName = $value->originalName; - $this->link = $value->link; - $this->fileId = $value->fileId; - $this->name = $value->name; - $this->url = $value->url; - $this->accountId = $value->accountId; - $this->dateCreated = $value->dateCreated; - $this->dateUpdated = $value->dateUpdated; + $this->type = $value->type ?? null; + $this->originalName = $value->originalName ?? null; + $this->link = $value->link ?? null; + $this->fileId = $value->fileId ?? null; + $this->name = $value->name ?? null; + $this->url = $value->url ?? null; + $this->accountId = $value->accountId ?? null; + $this->dateCreated = $value->dateCreated ?? null; + $this->dateUpdated = $value->dateUpdated ?? null; } -} \ No newline at end of file +} diff --git a/src/Services/SolapiMessageService.php b/src/Services/SolapiMessageService.php index e6fbd7e..95bf5f1 100644 --- a/src/Services/SolapiMessageService.php +++ b/src/Services/SolapiMessageService.php @@ -54,10 +54,11 @@ public function send($messages, ?DateTime $scheduledDateTime = null): SendRespon $result = $this->fetcherInstance->request("POST", "/messages/v4/send-many/detail", $requestParameter); $response = new SendResponse($result); - $count = $response->groupInfo->count; + $count = $response->groupInfo !== null ? $response->groupInfo->count : null; if ( + $count !== null && count($response->failedMessageList) > 0 && - ($count->total === $count->registeredFailed) + $count->total === $count->registeredFailed ) { throw new MessageNotReceivedException($response->failedMessageList); } @@ -110,7 +111,7 @@ public function getMessages(?GetMessagesRequest $parameter = null): ?GetMessages { try { $result = $this->fetcherInstance->request("GET", "/messages/v4/list", $parameter); - return new GetMessagesResponse($result); + return $result !== null ? new GetMessagesResponse($result) : null; } catch (Exception $exception) { return null; } @@ -125,7 +126,7 @@ public function getGroups(?GetGroupsRequest $parameter = null): ?GetGroupsRespon { try { $result = $this->fetcherInstance->request("GET", "/messages/v4/groups", $parameter); - return new GetGroupsResponse($result); + return $result !== null ? new GetGroupsResponse($result) : null; } catch (Exception $exception) { return null; } @@ -139,7 +140,8 @@ public function getGroups(?GetGroupsRequest $parameter = null): ?GetGroupsRespon public function getGroup(string $groupId): ?GroupMessageResponse { try { - return $this->fetcherInstance->request("GET", "/messages/v4/groups/$groupId"); + $result = $this->fetcherInstance->request("GET", "/messages/v4/groups/$groupId"); + return $result !== null ? new GroupMessageResponse($result) : null; } catch (Exception $exception) { return null; } @@ -154,7 +156,8 @@ public function getGroup(string $groupId): ?GroupMessageResponse public function getGroupMessages(string $groupId, ?GetGroupMessagesRequest $parameter = null): ?GetGroupMessagesResponse { try { - return $this->fetcherInstance->request("GET", "/messages/v4/groups/$groupId/messages", $parameter); + $result = $this->fetcherInstance->request("GET", "/messages/v4/groups/$groupId/messages", $parameter); + return $result !== null ? new GetGroupMessagesResponse($result) : null; } catch (Exception $exception) { return null; } @@ -168,7 +171,8 @@ public function getGroupMessages(string $groupId, ?GetGroupMessagesRequest $para public function getStatistics(?GetStatisticsRequest $parameter = null): ?GetStatisticsResponse { try { - return $this->fetcherInstance->request("GET", "/messages/v4/statistics", $parameter); + $result = $this->fetcherInstance->request("GET", "/messages/v4/statistics", $parameter); + return $result !== null ? new GetStatisticsResponse($result) : null; } catch (Exception $exception) { return null; } @@ -181,7 +185,8 @@ public function getStatistics(?GetStatisticsRequest $parameter = null): ?GetStat public function getBalance(): ?GetBalanceResponse { try { - return $this->fetcherInstance->request("GET", "/cash/v1/balance"); + $result = $this->fetcherInstance->request("GET", "/cash/v1/balance"); + return $result !== null ? new GetBalanceResponse($result) : null; } catch (Exception $exception) { return null; } diff --git a/tests/E2E/BmsFreeSendTest.php b/tests/E2E/BmsFreeSendTest.php index 7fc70d5..95d575f 100644 --- a/tests/E2E/BmsFreeSendTest.php +++ b/tests/E2E/BmsFreeSendTest.php @@ -38,11 +38,20 @@ class BmsFreeSendTest extends TestCase { - private ?SolapiMessageService $messageService = null; - private string $pfId = ''; - private string $senderNumber = ''; - private string $recipientNumber = ''; - private ?string $testImagePath = null; + /** @var SolapiMessageService|null */ + private $messageService = null; + + /** @var string */ + private $pfId = ''; + + /** @var string */ + private $senderNumber = ''; + + /** @var string */ + private $recipientNumber = ''; + + /** @var string|null */ + private $testImagePath = null; protected function setUp(): void { diff --git a/tests/Models/Response/ResponseConstructorTest.php b/tests/Models/Response/ResponseConstructorTest.php new file mode 100644 index 0000000..8c79db1 --- /dev/null +++ b/tests/Models/Response/ResponseConstructorTest.php @@ -0,0 +1,388 @@ +obj([ + 'total' => 10, 'sendTotal' => 9, 'sentFailed' => 1, 'sentSuccess' => 8, + 'sentPending' => 0, 'sentReplacement' => 0, 'refund' => 0, + 'registeredFailed' => 2, 'registeredSuccess' => 8, + ])); + + $this->assertSame(10, $c->total); + $this->assertSame(8, $c->registeredSuccess); + + $empty = new GroupCount($this->obj([])); + $this->assertNull($empty->total); + $this->assertNull($empty->registeredSuccess); + } + + public function testCommonCashResponseMapsCashFields(): void + { + $c = new CommonCashResponse($this->obj(['requested' => 100, 'replacement' => 5, 'refund' => 0, 'sum' => 105])); + $this->assertSame(100, $c->requested); + $this->assertSame(105, $c->sum); + } + + public function testMessageTypeMapsAllMessageCategoryCounts(): void + { + $m = new MessageType($this->obj([ + 'total' => 100, 'sms' => 50, 'lms' => 30, 'mms' => 5, + 'ata' => 10, 'cta' => 5, 'cti' => 0, 'nsa' => 0, + 'rcs_sms' => 0, 'rcs_lms' => 0, 'rcs_mms' => 0, 'rcs_tpl' => 0, + ])); + $this->assertSame(100, $m->total); + $this->assertSame(50, $m->sms); + $this->assertSame(30, $m->lms); + } + + public function testFailedMessageMapsFieldsAndFallsBackToNullWhenMissing(): void + { + $f = new FailedMessage($this->obj(['to' => '01012345678', 'from' => '01087654321'])); + $this->assertSame('01012345678', $f->to); + $this->assertSame('01087654321', $f->from); + $this->assertNull($f->statusCode); + $this->assertNull($f->accountId); + } + + public function testErrorResponseMapsMissingFieldsToNull(): void + { + $e = new ErrorResponse($this->obj([])); + + $this->assertNull($e->errorCode); + $this->assertNull($e->errorMessage); + } + + public function testUploadFileResponseMapsMissingFieldsToNull(): void + { + $u = new UploadFileResponse($this->obj(['fileId' => 'FILE123'])); + + $this->assertSame('FILE123', $u->fileId); + $this->assertNull($u->type); + $this->assertNull($u->dateUpdated); + } + + /** + * @return array + */ + public function providePublicResponseClasses(): array + { + return [ + 'CommonCashResponse' => [CommonCashResponse::class], + 'ErrorResponse' => [ErrorResponse::class], + 'FailedMessage' => [FailedMessage::class], + 'GetBalanceResponse' => [GetBalanceResponse::class], + 'GetGroupMessagesResponse' => [GetGroupMessagesResponse::class], + 'GetGroupsResponse' => [GetGroupsResponse::class], + 'GetMessagesResponse' => [GetMessagesResponse::class], + 'GetStatisticsResponse' => [GetStatisticsResponse::class], + 'GroupCount' => [GroupCount::class], + 'GroupCountForCharge' => [GroupCountForCharge::class], + 'GroupMessageResponse' => [GroupMessageResponse::class], + 'MessageType' => [MessageType::class], + 'SendResponse' => [SendResponse::class], + 'StatisticsDayPeriod' => [StatisticsDayPeriod::class], + 'StatisticsMonthPeriod' => [StatisticsMonthPeriod::class], + 'UploadFileResponse' => [UploadFileResponse::class], + ]; + } + + /** + * @dataProvider providePublicResponseClasses + */ + public function testPublicResponseClassesRemainDefaultConstructible(string $class): void + { + $response = new $class(); + + $this->assertInstanceOf($class, $response); + if ($response instanceof SendResponse) { + $this->assertSame([], $response->failedMessageList); + } + } + + public function testGroupCountForChargeMapsObjectFieldsAndKeepsStdClass(): void + { + $g = new GroupCountForCharge($this->obj([ + 'sms' => ['requested' => 10], + 'lms' => ['requested' => 5], + ])); + $this->assertNotNull($g->sms); + $this->assertNotNull($g->lms); + $this->assertNull($g->mms); + } + + // ---------- GetBalanceResponse ---------- + + public function testGetBalanceResponseMapsBalanceAndPointWithNullFallback(): void + { + $r = new GetBalanceResponse($this->obj(['balance' => 1500.5, 'point' => 200.25])); + $this->assertSame(1500.5, $r->balance); + $this->assertSame(200.25, $r->point); + + $empty = new GetBalanceResponse($this->obj([])); + $this->assertNull($empty->balance); + $this->assertNull($empty->point); + } + + // ---------- GroupMessageResponse: nested object conversion ---------- + + public function testGroupMessageResponseConvertsCountIntoGroupCountInstance(): void + { + $r = new GroupMessageResponse($this->obj([ + 'groupId' => 'G4V01', + 'count' => ['total' => 10, 'registeredFailed' => 2], + ])); + + $this->assertSame('G4V01', $r->groupId); + $this->assertInstanceOf(GroupCount::class, $r->count); + $this->assertSame(10, $r->count->total); + } + + public function testGroupMessageResponseConvertsBalanceAndPointIntoCommonCashResponse(): void + { + $r = new GroupMessageResponse($this->obj([ + 'balance' => ['requested' => 100, 'sum' => 100], + 'point' => ['requested' => 10, 'sum' => 10], + ])); + + $this->assertInstanceOf(CommonCashResponse::class, $r->balance); + $this->assertInstanceOf(CommonCashResponse::class, $r->point); + $this->assertSame(100, $r->balance->requested); + } + + public function testGroupMessageResponseLeavesAllNestedFieldsNullWhenMissing(): void + { + $r = new GroupMessageResponse($this->obj([])); + + $this->assertNull($r->count); + $this->assertNull($r->countForCharge); + $this->assertNull($r->balance); + $this->assertNull($r->point); + $this->assertNull($r->groupId); + $this->assertNull($r->allowDuplicates); + } + + // ---------- GetStatisticsResponse: array conversion ---------- + + public function testGetStatisticsResponseConvertsMonthPeriodArrayElements(): void + { + $r = new GetStatisticsResponse($this->obj([ + 'balance' => 1000, + 'monthPeriod' => [ + ['date' => '2026-04'], + ['date' => '2026-05'], + ], + 'total' => ['total' => 100, 'sms' => 50], + ])); + + $this->assertIsArray($r->monthPeriod); + $this->assertCount(2, $r->monthPeriod); + $this->assertContainsOnlyInstancesOf(StatisticsMonthPeriod::class, $r->monthPeriod); + $this->assertSame('2026-04', $r->monthPeriod[0]->date); + + $this->assertInstanceOf(MessageType::class, $r->total); + $this->assertSame(100, $r->total->total); + } + + public function testGetStatisticsResponseAcceptsEmptyMonthPeriodArray(): void + { + $r = new GetStatisticsResponse($this->obj(['monthPeriod' => []])); + + $this->assertIsArray($r->monthPeriod); + $this->assertSame([], $r->monthPeriod); + } + + public function testGetStatisticsResponseLeavesMonthPeriodNullWhenAbsent(): void + { + $r = new GetStatisticsResponse($this->obj([])); + $this->assertNull($r->monthPeriod); + $this->assertNull($r->total); + } + + // ---------- StatisticsMonthPeriod and StatisticsDayPeriod ---------- + + public function testStatisticsMonthPeriodConvertsDayPeriodIntoStatisticsDayPeriodArray(): void + { + $p = new StatisticsMonthPeriod($this->obj([ + 'date' => '2026-05', + 'dayPeriod' => [ + ['month' => '2026-05-01'], + ['month' => '2026-05-02'], + ], + ])); + + $this->assertIsArray($p->dayPeriod); + $this->assertCount(2, $p->dayPeriod); + $this->assertContainsOnlyInstancesOf(StatisticsDayPeriod::class, $p->dayPeriod); + } + + public function testStatisticsDayPeriodConvertsStatusCodeArrayIntoMessageTypeInstances(): void + { + $d = new StatisticsDayPeriod($this->obj([ + 'month' => '2026-05-01', + 'statusCode' => [ + ['total' => 10], + ['total' => 20], + ], + 'total' => ['total' => 30], + ])); + + $this->assertIsArray($d->statusCode); + $this->assertCount(2, $d->statusCode); + $this->assertContainsOnlyInstancesOf(MessageType::class, $d->statusCode); + $this->assertSame(10, $d->statusCode[0]->total); + $this->assertInstanceOf(MessageType::class, $d->total); + } + + // ---------- GetGroupsResponse: object-shaped groupList ---------- + + public function testGetGroupsResponseHandlesObjectKeyedGroupList(): void + { + $r = new GetGroupsResponse($this->obj([ + 'limit' => 20, + 'groupList' => [ + 'G4V01' => ['groupId' => 'G4V01'], + 'G4V02' => ['groupId' => 'G4V02'], + ], + ])); + + $this->assertIsArray($r->groupList); + $this->assertCount(2, $r->groupList); + $this->assertContainsOnlyInstancesOf(GroupMessageResponse::class, $r->groupList); + $this->assertSame('G4V01', $r->groupList[0]->groupId); + $this->assertSame('G4V02', $r->groupList[1]->groupId); + } + + public function testGetGroupsResponseHandlesArrayShapedGroupList(): void + { + // Explicit JSON-array input path (vs object-keyed input) + $value = new stdClass(); + $value->limit = 20; + $value->groupList = [ + $this->obj(['groupId' => 'G4V01']), + $this->obj(['groupId' => 'G4V02']), + ]; + + $r = new GetGroupsResponse($value); + + $this->assertCount(2, $r->groupList); + $this->assertContainsOnlyInstancesOf(GroupMessageResponse::class, $r->groupList); + } + + public function testGetGroupsResponseLeavesGroupListNullWhenAbsent(): void + { + $r = new GetGroupsResponse($this->obj([])); + $this->assertNull($r->groupList); + } + + // ---------- Message list responses: normalize object-keyed lists without losing fields ---------- + + public function testGetMessagesResponseNormalizesObjectKeyedMessageList(): void + { + $r = new GetMessagesResponse($this->obj([ + 'messageList' => [ + 'M4V01' => ['messageId' => 'M4V01', 'statusCode' => '2000'], + 'M4V02' => ['messageId' => 'M4V02', 'statusCode' => '4000'], + ], + ])); + + $this->assertIsArray($r->messageList); + $this->assertCount(2, $r->messageList); + $this->assertSame('M4V01', $r->messageList[0]->messageId); + $this->assertSame('2000', $r->messageList[0]->statusCode); + $this->assertSame('M4V02', $r->messageList[1]->messageId); + } + + public function testGetGroupMessagesResponseNormalizesArrayShapedMessageList(): void + { + $value = new stdClass(); + $value->messageList = [ + $this->obj(['messageId' => 'M4V01', 'statusCode' => '2000']), + $this->obj(['messageId' => 'M4V02', 'statusCode' => '4000']), + ]; + + $r = new GetGroupMessagesResponse($value); + + $this->assertIsArray($r->messageList); + $this->assertCount(2, $r->messageList); + $this->assertSame('M4V01', $r->messageList[0]->messageId); + $this->assertSame('4000', $r->messageList[1]->statusCode); + } + + // ---------- SendResponse: groupInfo + failedMessageList conversion ---------- + + public function testSendResponseConvertsGroupInfoAndFailedMessageList(): void + { + $r = new SendResponse($this->obj([ + 'groupInfo' => [ + 'groupId' => 'G4V01', + 'count' => ['total' => 1, 'registeredFailed' => 0], + ], + 'failedMessageList' => [ + ['to' => '01000000000', 'statusCode' => 'N000'], + ], + ])); + + $this->assertInstanceOf(GroupMessageResponse::class, $r->groupInfo); + $this->assertInstanceOf(GroupCount::class, $r->groupInfo->count); + $this->assertSame(1, $r->groupInfo->count->total); + + $this->assertIsArray($r->failedMessageList); + $this->assertCount(1, $r->failedMessageList); + $this->assertContainsOnlyInstancesOf(FailedMessage::class, $r->failedMessageList); + $this->assertSame('01000000000', $r->failedMessageList[0]->to); + } + + public function testSendResponseDefaultsFailedMessageListToEmptyArray(): void + { + $r = new SendResponse($this->obj(['groupInfo' => ['groupId' => 'G4V01']])); + + $this->assertSame([], $r->failedMessageList); + $this->assertInstanceOf(GroupMessageResponse::class, $r->groupInfo); + } + + public function testSendResponseJsonSerializeRoundTrip(): void + { + $r = new SendResponse($this->obj([ + 'groupInfo' => ['groupId' => 'G4V01'], + 'failedMessageList' => [], + ])); + + $json = json_encode($r); + $this->assertJson($json); + $decoded = json_decode($json, true); + $this->assertArrayHasKey('groupInfo', $decoded); + $this->assertArrayHasKey('failedMessageList', $decoded); + } +} diff --git a/tests/Services/FakeHttpClient.php b/tests/Services/FakeHttpClient.php new file mode 100644 index 0000000..f365757 --- /dev/null +++ b/tests/Services/FakeHttpClient.php @@ -0,0 +1,64 @@ + */ + private $responses = []; + + /** @var RequestInterface[] */ + public $receivedRequests = []; + + /** @var ?\Throwable */ + private $exceptionToThrow = null; + + public function respondTo(string $method, string $path, int $status, string $body): void + { + $this->responses[$this->key($method, $path)] = [$status, $body]; + } + + public function throwOnceOnNextRequest(\Throwable $exception): void + { + $this->exceptionToThrow = $exception; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->receivedRequests[] = $request; + + if ($this->exceptionToThrow !== null) { + $e = $this->exceptionToThrow; + $this->exceptionToThrow = null; + if ($e instanceof ClientExceptionInterface) { + throw $e; + } + throw new RuntimeException($e->getMessage(), 0, $e); + } + + $key = $this->key($request->getMethod(), $request->getUri()->getPath()); + if (!isset($this->responses[$key])) { + throw new RuntimeException("No canned response registered for: $key"); + } + [$status, $body] = $this->responses[$key]; + return new Response($status, ['Content-Type' => 'application/json'], $body); + } + + private function key(string $method, string $path): string + { + return strtoupper($method) . ' ' . $path; + } +} diff --git a/tests/Services/SolapiMessageServiceTest.php b/tests/Services/SolapiMessageServiceTest.php new file mode 100644 index 0000000..4b1ebc1 --- /dev/null +++ b/tests/Services/SolapiMessageServiceTest.php @@ -0,0 +1,388 @@ +http = new FakeHttpClient(); + $this->service = new SolapiMessageService('TEST_KEY', 'TEST_SECRET', $this->http); + } + + protected function tearDown(): void + { + Fetcher::resetForTesting(); + } + + // ---------- Regression: issue #26 (TypeError on get* methods) ---------- + + /** + * @return array + */ + public function provideGetMethodsRegression(): array + { + return [ + 'getBalance returns GetBalanceResponse, not stdClass' => [ + 'getBalance', + 'GET', + '/cash/v1/balance', + '{"balance":1500.5,"point":200}', + GetBalanceResponse::class, + ], + 'getGroup returns GroupMessageResponse, not stdClass' => [ + 'getGroup', + 'GET', + '/messages/v4/groups/G4V01', + '{"groupId":"G4V01","status":"COMPLETE","accountId":"129"}', + GroupMessageResponse::class, + ], + 'getGroupMessages returns GetGroupMessagesResponse, not stdClass' => [ + 'getGroupMessages', + 'GET', + '/messages/v4/groups/G4V01/messages', + '{"limit":20,"messageList":{},"startKey":null,"nextKey":"abc"}', + GetGroupMessagesResponse::class, + ], + 'getStatistics returns GetStatisticsResponse, not stdClass' => [ + 'getStatistics', + 'GET', + '/messages/v4/statistics', + '{"balance":1000,"point":100,"monthPeriod":[]}', + GetStatisticsResponse::class, + ], + ]; + } + + /** + * @dataProvider provideGetMethodsRegression + */ + public function testGetMethodsReturnTypedResponseObjectAndNotStdClass( + string $method, + string $httpMethod, + string $path, + string $body, + string $expectedClass + ): void { + $this->http->respondTo($httpMethod, $path, 200, $body); + + $arg = $method === 'getGroup' || $method === 'getGroupMessages' ? 'G4V01' : null; + $result = $arg !== null ? $this->service->$method($arg) : $this->service->$method(); + + $this->assertInstanceOf($expectedClass, $result); + } + + // ---------- Success path: field mapping ---------- + + public function testGetBalanceMapsBalanceAndPointFromApiResponse(): void + { + $this->http->respondTo('GET', '/cash/v1/balance', 200, '{"balance":1500.5,"point":200.25}'); + + $response = $this->service->getBalance(); + + $this->assertSame(1500.5, $response->balance); + $this->assertSame(200.25, $response->point); + } + + public function testGetGroupConvertsNestedCountIntoGroupCountInstance(): void + { + $body = json_encode([ + 'groupId' => 'G4V01', + 'status' => 'COMPLETE', + 'count' => [ + 'total' => 10, + 'registeredFailed' => 2, + 'registeredSuccess' => 8, + ], + ]); + $this->http->respondTo('GET', '/messages/v4/groups/G4V01', 200, $body); + + $response = $this->service->getGroup('G4V01'); + + $this->assertInstanceOf(GroupCount::class, $response->count); + $this->assertSame(10, $response->count->total); + $this->assertSame(2, $response->count->registeredFailed); + $this->assertSame(8, $response->count->registeredSuccess); + } + + public function testGetStatisticsConvertsTotalIntoMessageTypeAndMonthPeriodArray(): void + { + $body = json_encode([ + 'balance' => 1000, + 'point' => 100, + 'total' => ['total' => 50, 'sms' => 30, 'lms' => 20], + 'monthPeriod' => [ + ['date' => '2026-04', 'balance' => 100], + ['date' => '2026-05', 'balance' => 200], + ], + ]); + $this->http->respondTo('GET', '/messages/v4/statistics', 200, $body); + + $response = $this->service->getStatistics(); + + $this->assertInstanceOf(MessageType::class, $response->total); + $this->assertSame(50, $response->total->total); + $this->assertSame(30, $response->total->sms); + + $this->assertIsArray($response->monthPeriod); + $this->assertCount(2, $response->monthPeriod); + $this->assertContainsOnlyInstancesOf(StatisticsMonthPeriod::class, $response->monthPeriod); + $this->assertSame('2026-04', $response->monthPeriod[0]->date); + } + + // ---------- Boundary: missing fields, null values, empty bodies ---------- + + public function testGetBalanceHandlesMissingFieldsAsNull(): void + { + $this->http->respondTo('GET', '/cash/v1/balance', 200, '{}'); + + $response = $this->service->getBalance(); + + $this->assertInstanceOf(GetBalanceResponse::class, $response); + $this->assertNull($response->balance); + $this->assertNull($response->point); + } + + public function testGetGroupHandlesMissingNestedObjectsAsNull(): void + { + $this->http->respondTo('GET', '/messages/v4/groups/G4V01', 200, '{"groupId":"G4V01"}'); + + $response = $this->service->getGroup('G4V01'); + + $this->assertSame('G4V01', $response->groupId); + $this->assertNull($response->count); + $this->assertNull($response->countForCharge); + $this->assertNull($response->balance); + $this->assertNull($response->point); + } + + public function testGetStatisticsHandlesMissingMonthPeriodAsNull(): void + { + $this->http->respondTo('GET', '/messages/v4/statistics', 200, '{}'); + + $response = $this->service->getStatistics(); + + $this->assertNull($response->monthPeriod); + $this->assertNull($response->total); + } + + public function testGetGroupsConvertsObjectShapedGroupListIntoArrayOfGroupMessageResponse(): void + { + // SOLAPI returns groupList as an object keyed by groupId, not as an array + $body = json_encode([ + 'limit' => 20, + 'groupList' => [ + 'G4V01' => ['groupId' => 'G4V01', 'status' => 'COMPLETE'], + 'G4V02' => ['groupId' => 'G4V02', 'status' => 'PENDING'], + ], + ]); + $this->http->respondTo('GET', '/messages/v4/groups', 200, $body); + + $response = $this->service->getGroups(); + + $this->assertInstanceOf(GetGroupsResponse::class, $response); + $this->assertIsArray($response->groupList); + $this->assertCount(2, $response->groupList); + $this->assertContainsOnlyInstancesOf(GroupMessageResponse::class, $response->groupList); + } + + public function testGetMessagesNormalizesObjectShapedMessageListAndPreservesResponseFields(): void + { + $body = json_encode([ + 'limit' => 20, + 'messageList' => [ + 'M4V01' => ['messageId' => 'M4V01', 'statusCode' => '2000'], + 'M4V02' => ['messageId' => 'M4V02', 'statusCode' => '4000'], + ], + ]); + $this->http->respondTo('GET', '/messages/v4/list', 200, $body); + + $response = $this->service->getMessages(); + + $this->assertInstanceOf(GetMessagesResponse::class, $response); + $this->assertIsArray($response->messageList); + $this->assertCount(2, $response->messageList); + $this->assertSame('M4V01', $response->messageList[0]->messageId); + $this->assertSame('4000', $response->messageList[1]->statusCode); + } + + // ---------- Failure path: get* methods swallow exceptions and return null ---------- + + /** + * @return array + */ + public function provideHttpErrors(): array + { + return [ + 'getBalance 4xx returns null' => ['getBalance', '/cash/v1/balance', 400, '{"errorCode":"E400","errorMessage":"bad"}'], + 'getBalance 5xx returns null' => ['getBalance', '/cash/v1/balance', 500, ''], + 'getGroup 404 returns null' => ['getGroup', '/messages/v4/groups/G4V01', 404, '{"errorCode":"NotFound","errorMessage":"missing"}'], + 'getGroupMessages 4xx null' => ['getGroupMessages', '/messages/v4/groups/G4V01/messages', 401, '{"errorCode":"Unauthorized","errorMessage":"x"}'], + 'getStatistics 5xx null' => ['getStatistics', '/messages/v4/statistics', 502, ''], + 'getMessages 4xx null' => ['getMessages', '/messages/v4/list', 403, '{"errorCode":"Forbidden","errorMessage":"x"}'], + 'getGroups 5xx null' => ['getGroups', '/messages/v4/groups', 500, ''], + ]; + } + + /** + * @dataProvider provideHttpErrors + */ + public function testGetMethodsReturnNullOnHttpError( + string $method, + string $path, + int $status, + string $body + ): void { + $this->http->respondTo('GET', $path, $status, $body); + + $arg = $method === 'getGroup' || $method === 'getGroupMessages' ? 'G4V01' : null; + $result = $arg !== null ? $this->service->$method($arg) : $this->service->$method(); + + $this->assertNull($result); + } + + public function testGetStatisticsReturnsNullWhenBodyIsNotJson(): void + { + // 200 OK with a non-JSON body causes Fetcher to return null + // (json_decode failure), which the service guards by returning null + // instead of constructing a Response object with all-null fields. + $this->http->respondTo('GET', '/messages/v4/statistics', 200, 'not json'); + + $result = $this->service->getStatistics(); + + $this->assertNull($result); + } + + public function testGetBalanceReturnsNullForEmptyServerErrorBodyWithoutPhpWarning(): void + { + $errors = []; + set_error_handler(static function ($severity, $message) use (&$errors) { + $errors[] = $message; + return true; + }); + + try { + $this->http->respondTo('GET', '/cash/v1/balance', 500, ''); + + $result = $this->service->getBalance(); + } finally { + restore_error_handler(); + } + + $this->assertNull($result); + $this->assertSame([], $errors); + } + + public function testGetBalanceReturnsNullWhenHttpClientThrows(): void + { + $this->http->throwOnceOnNextRequest(new class extends \RuntimeException implements \Psr\Http\Client\ClientExceptionInterface {}); + + $result = $this->service->getBalance(); + + $this->assertNull($result); + } + + // ---------- Regression: send() must not NPE when groupInfo is missing ---------- + + public function testSendDoesNotThrowWhenApiResponseOmitsGroupInfo(): void + { + $message = new \Nurigo\Solapi\Models\Message(); + $message->setTo('01000000000')->setFrom('01087654321')->setText('hi'); + + $this->http->respondTo( + 'POST', + '/messages/v4/send-many/detail', + 200, + '{"failedMessageList":[]}' + ); + + $response = $this->service->send($message); + + $this->assertInstanceOf(\Nurigo\Solapi\Models\Response\SendResponse::class, $response); + $this->assertNull($response->groupInfo); + $this->assertSame([], $response->failedMessageList); + } + + public function testSendThrowsMessageNotReceivedExceptionOnlyWhenAllRegistrationsFailed(): void + { + $message = new \Nurigo\Solapi\Models\Message(); + $message->setTo('01000000000')->setFrom('01087654321')->setText('hi'); + + $body = json_encode([ + 'groupInfo' => [ + 'groupId' => 'G4V01', + 'count' => ['total' => 1, 'registeredFailed' => 1, 'registeredSuccess' => 0], + ], + 'failedMessageList' => [ + ['to' => '01000000000', 'statusCode' => 'N000'], + ], + ]); + $this->http->respondTo('POST', '/messages/v4/send-many/detail', 200, $body); + + $this->expectException(\Nurigo\Solapi\Exceptions\MessageNotReceivedException::class); + + $this->service->send($message); + } + + // ---------- Side-effect verification: SDK actually issues the documented request ---------- + + public function testGetBalanceIssuesGetRequestToCashBalanceEndpoint(): void + { + $this->http->respondTo('GET', '/cash/v1/balance', 200, '{"balance":0,"point":0}'); + + $this->service->getBalance(); + + $this->assertCount(1, $this->http->receivedRequests); + $req = $this->http->receivedRequests[0]; + $this->assertSame('GET', $req->getMethod()); + $this->assertSame('/cash/v1/balance', $req->getUri()->getPath()); + $this->assertNotEmpty($req->getHeaderLine('Authorization')); + } + + public function testGetGroupMessagesAppendsGroupIdToPath(): void + { + $this->http->respondTo('GET', '/messages/v4/groups/G4V123/messages', 200, '{"limit":20,"messageList":{}}'); + + $this->service->getGroupMessages('G4V123'); + + $this->assertCount(1, $this->http->receivedRequests); + $this->assertSame( + '/messages/v4/groups/G4V123/messages', + $this->http->receivedRequests[0]->getUri()->getPath() + ); + } + + // ---------- Idempotency: repeated calls return equivalent results ---------- + + public function testGetBalanceIsIdempotentWhenApiResponseUnchanged(): void + { + $this->http->respondTo('GET', '/cash/v1/balance', 200, '{"balance":1000,"point":50}'); + + $a = $this->service->getBalance(); + $this->http->respondTo('GET', '/cash/v1/balance', 200, '{"balance":1000,"point":50}'); + $b = $this->service->getBalance(); + + $this->assertEquals($a, $b); + $this->assertNotSame($a, $b, 'Each call should return a fresh instance'); + } +}