diff --git a/.claude/scripts/sync-version.sh b/.claude/scripts/sync-version.sh
new file mode 100755
index 0000000..733c1e2
--- /dev/null
+++ b/.claude/scripts/sync-version.sh
@@ -0,0 +1,43 @@
+#!/bin/bash
+# 버전 동기화 스크립트
+# build.gradle.kts의 버전이 변경되면 README.md와 LLM_GUIDE.md에 반영
+
+set -e
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
+
+GRADLE_FILE="$PROJECT_ROOT/build.gradle.kts"
+README_FILE="$PROJECT_ROOT/README.md"
+LLM_GUIDE_FILE="$PROJECT_ROOT/LLM_GUIDE.md"
+
+# build.gradle.kts가 수정된 경우에만 실행
+if [[ -n "$CLAUDE_FILE_PATHS" ]]; then
+ if ! echo "$CLAUDE_FILE_PATHS" | grep -q "build.gradle.kts"; then
+ exit 0
+ fi
+fi
+
+# build.gradle.kts에서 버전 추출 (예: version = "1.1.0" -> 1.1.0)
+VERSION=$(grep -E '^version\s*=' "$GRADLE_FILE" | head -1 | sed -E 's/.*"([0-9]+\.[0-9]+\.[0-9]+)".*/\1/')
+
+if [[ -z "$VERSION" ]] || [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "유효한 버전을 찾을 수 없습니다: $VERSION"
+ exit 1
+fi
+
+# README.md 버전 업데이트
+if [[ -f "$README_FILE" ]]; then
+ # com.solapi:sdk:X.X.X 패턴 업데이트
+ sed -i '' -E "s/(com\.solapi:sdk:)[0-9]+\.[0-9]+\.[0-9]+/\1$VERSION/g" "$README_FILE"
+ # X.X.X 패턴
+ sed -i '' -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1$VERSION\2|g" "$README_FILE"
+fi
+
+# LLM_GUIDE.md 버전 업데이트
+if [[ -f "$LLM_GUIDE_FILE" ]]; then
+ sed -i '' -E "s/(com\.solapi:sdk:)[0-9]+\.[0-9]+\.[0-9]+/\1$VERSION/g" "$LLM_GUIDE_FILE"
+ sed -i '' -E "s|()[0-9]+\.[0-9]+\.[0-9]+()|\1$VERSION\2|g" "$LLM_GUIDE_FILE"
+fi
+
+echo "버전 $VERSION 동기화 완료"
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..62cbf75
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,10 @@
+# SOLAPI API Credentials
+SOLAPI_API_KEY=
+SOLAPI_API_SECRET=
+
+# Kakao Business Channel
+KAKAO_PF_ID=
+
+# Phone Numbers (Optional)
+SENDER_NUMBER=
+TEST_PHONE_NUMBER=
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..c91eaf1
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,54 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+# E2E 테스트에 필요한 환경변수 (선택사항 - 설정되지 않으면 E2E 테스트 건너뜀)
+#
+# 기본 환경변수:
+# SOLAPI_API_KEY - Solapi API 키
+# SOLAPI_API_SECRET - Solapi API 시크릿
+# SOLAPI_SENDER - 등록된 발신번호
+# SOLAPI_RECIPIENT - 테스트 수신번호
+#
+# 카카오 테스트 환경변수 (선택):
+# SOLAPI_KAKAO_PF_ID - 카카오 비즈니스 채널 ID
+# SOLAPI_KAKAO_TEMPLATE_ID - 카카오 알림톡 템플릿 ID
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ java-version: ['21']
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK ${{ matrix.java-version }}
+ uses: actions/setup-java@v4
+ with:
+ java-version: ${{ matrix.java-version }}
+ distribution: temurin
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Build
+ run: ./gradlew build -x test
+
+ - name: Test
+ run: ./gradlew test
+ env:
+ SOLAPI_API_KEY: ${{ secrets.SOLAPI_API_KEY }}
+ SOLAPI_API_SECRET: ${{ secrets.SOLAPI_API_SECRET }}
+ SOLAPI_SENDER: ${{ secrets.SOLAPI_SENDER }}
+ SOLAPI_RECIPIENT: ${{ secrets.SOLAPI_RECIPIENT }}
+ SOLAPI_KAKAO_PF_ID: ${{ secrets.SOLAPI_KAKAO_PF_ID }}
+ SOLAPI_KAKAO_TEMPLATE_ID: ${{ secrets.SOLAPI_KAKAO_TEMPLATE_ID }}
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..e3505c7
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,60 @@
+name: Publish to Maven Central
+
+on:
+ release:
+ types: [published]
+
+permissions:
+ contents: read
+
+# 배포에 필요한 Secrets:
+# MAVEN_CENTRAL_USERNAME - Central Portal User Token username
+# MAVEN_CENTRAL_TOKEN - Central Portal User Token password
+# GPG_SIGNING_KEY - GPG 비밀 키 (armor 형식)
+# GPG_SIGNING_PASSWORD - GPG 키 비밀번호
+#
+# E2E 테스트에 필요한 환경변수 (선택사항 - 설정되지 않으면 E2E 테스트 건너뜀)
+#
+# 기본 환경변수:
+# SOLAPI_API_KEY - Solapi API 키
+# SOLAPI_API_SECRET - Solapi API 시크릿
+# SOLAPI_SENDER - 등록된 발신번호
+# SOLAPI_RECIPIENT - 테스트 수신번호
+#
+# 카카오 테스트 환경변수 (선택):
+# SOLAPI_KAKAO_PF_ID - 카카오 비즈니스 채널 ID
+# SOLAPI_KAKAO_TEMPLATE_ID - 카카오 알림톡 템플릿 ID
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: 21
+ distribution: temurin
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Run tests
+ run: ./gradlew test
+ env:
+ SOLAPI_API_KEY: ${{ secrets.SOLAPI_API_KEY }}
+ SOLAPI_API_SECRET: ${{ secrets.SOLAPI_API_SECRET }}
+ SOLAPI_SENDER: ${{ secrets.SOLAPI_SENDER }}
+ SOLAPI_RECIPIENT: ${{ secrets.SOLAPI_RECIPIENT }}
+ SOLAPI_KAKAO_PF_ID: ${{ secrets.SOLAPI_KAKAO_PF_ID }}
+ SOLAPI_KAKAO_TEMPLATE_ID: ${{ secrets.SOLAPI_KAKAO_TEMPLATE_ID }}
+
+ - name: Publish to Maven Central
+ run: ./gradlew publishAndReleaseToMavenCentral --no-configuration-cache
+ env:
+ ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_TOKEN }}
+ ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SIGNING_KEY }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SIGNING_PASSWORD }}
diff --git a/.gitignore b/.gitignore
index 90d6b69..7948d99 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,7 +34,13 @@ signing.gpg
*.gpg
*.asc
secret.key
+.env
+.env.local
# Generated files
/docs/
-manual/
\ No newline at end of file
+manual/
+
+# OMO, OMC
+.sisyphus/
+.omc/
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fd17cbe
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,187 @@
+# SOLAPI Kotlin SDK - Knowledge Base
+
+**Generated:** 2026-01-27 | **Commit:** 618f129 | **Branch:** main
+
+## CRITICAL: Development Principles
+
+**MUST follow `CLAUDE.md` development principles:**
+
+| Principle | Rule |
+|-----------|------|
+| **Tidy First** | NEVER mix structural and behavioral changes in a single commit |
+| **Commit Separation** | `refactor:` (structural) vs `feat:`/`fix:` (behavioral) in separate commits |
+| **TDD** | Write tests first (Red → Green → Refactor) |
+| **Single Responsibility** | Classes/methods have single responsibility only |
+| **Tidy Code First** | Clean up target area code before adding features |
+
+```bash
+# Correct commit order
+git commit -m "refactor: extract validation logic to separate method"
+git commit -m "feat: add phone number format validation"
+
+# Forbidden (mixed commit)
+git commit -m "feat: add validation and refactor code" # ❌ FORBIDDEN
+```
+
+---
+
+## OVERVIEW
+
+Kotlin/Java SDK for SOLAPI messaging platform. Supports SMS, LMS, MMS, Kakao Alimtalk/Brand Message, Naver Smart Notification, RCS, Fax, and Voice messaging.
+
+## STRUCTURE
+
+```
+src/main/java/com/solapi/sdk/
+├── SolapiClient.kt # Entry point (use this)
+├── NurigoApp.kt # DEPRECATED - do not use
+└── message/
+ ├── service/ # API operations (send, query, templates)
+ ├── model/ # Domain models (Message, options)
+ │ └── kakao/ # 19 files - Kakao templates, buttons, options
+ ├── dto/ # Request/Response DTOs
+ ├── exception/ # Exception hierarchy (8 types)
+ └── lib/ # Internal utilities (auth, helpers)
+```
+
+## WHERE TO LOOK
+
+| Task | Location | Notes |
+|------|----------|-------|
+| **Initialize SDK** | `SolapiClient.kt` | `createInstance(apiKey, secretKey)` |
+| **Send messages** | `service/DefaultMessageService.kt` | `send(message)` or `send(messages)` |
+| **Query messages** | `service/DefaultMessageService.kt` | `getMessageList(params)` |
+| **Upload files** | `service/DefaultMessageService.kt` | `uploadFile(file, type)` for MMS/Fax |
+| **Kakao Alimtalk** | `service/DefaultMessageService.kt` | 11 template methods |
+| **Create Message** | `model/Message.kt` | Data class with all message options |
+| **Kakao options** | `model/kakao/KakaoOption.kt` | Alimtalk/FriendTalk config |
+| **Handle errors** | `exception/` | Catch specific `Solapi*Exception` types |
+| **HTTP layer** | `service/MessageHttpService.kt` | Retrofit interface (internal) |
+| **Auth** | `lib/Authenticator.kt` | HMAC-SHA256 (internal, auto-injected) |
+
+## CODE PATTERNS
+
+### Serialization
+```kotlin
+@Serializable
+data class Message(
+ var to: String? = null,
+ var from: String? = null,
+ // All fields nullable with defaults for flexibility
+)
+```
+- **ALWAYS** use `@Serializable` annotation
+- **ALWAYS** use `kotlinx.serialization` (not Jackson/Gson)
+- **ALWAYS** provide nullable fields with defaults
+
+### Service Methods
+```kotlin
+@JvmOverloads // Java interop
+@Throws(SolapiMessageNotReceivedException::class, ...)
+fun send(messages: List, config: SendRequestConfig? = null): MultipleDetailMessageSentResponse
+```
+- **ALWAYS** annotate with `@JvmOverloads` for optional params
+- **ALWAYS** declare `@Throws` for checked exceptions
+
+### Exception Handling
+```kotlin
+// Internal: Map error codes to exceptions
+when (errorResponse.errorCode) {
+ "ValidationError" -> throw SolapiBadRequestException(msg)
+ "InvalidApiKey" -> throw SolapiInvalidApiKeyException(msg)
+ else -> throw SolapiUnknownException(msg)
+}
+```
+- Exceptions are `sealed interface` based (SolapiException)
+- 8 specific exception types
+
+### Phone Number Normalization
+```kotlin
+init {
+ from = from?.replace("-", "")
+ to = to?.replace("-", "")
+}
+```
+- Dashes auto-stripped from phone numbers in `Message.init`
+
+### Test Conventions
+```kotlin
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import kotlin.test.assertFailsWith
+
+class AuthenticatorTest {
+ @Test
+ fun `generateAuthInfo returns HMAC-SHA256 format`() {
+ // Given
+ val authenticator = Authenticator("api-key", "secret")
+ // When
+ val result = authenticator.generateAuthInfo()
+ // Then
+ assertTrue(result.startsWith("HMAC-SHA256 "))
+ }
+}
+```
+- **ALWAYS** use `kotlin.test` (NOT JUnit directly)
+- **ALWAYS** use Given-When-Then comment structure
+- **ALWAYS** use backtick method names for readability
+
+## ANTI-PATTERNS
+
+| Forbidden | Required |
+|-----------|----------|
+| `NurigoApp.initialize()` | `SolapiClient.createInstance()` |
+| Direct `DefaultMessageService()` | Use factory via `SolapiClient` |
+| Catch generic `Exception` | Catch specific `Solapi*Exception` |
+| `net.nurigo.sdk` imports | `com.solapi.sdk` package only |
+| Jackson/Gson serialization | `kotlinx.serialization` only |
+| Mixed structural+behavioral commits | Separate commits per Tidy First |
+
+## INTERNAL CLASSES (Do Not Use Directly)
+
+- `Authenticator` - HMAC auth (auto-injected via interceptor)
+- `ErrorResponse` - Internal error DTO
+- `MessageHttpService` - Retrofit interface
+- `JsonSupport` - Serialization config
+- `MapHelper`, `Criterion` - Internal utilities
+
+## EXCEPTION TYPES
+
+| Exception | When Thrown |
+|-----------|-------------|
+| `SolapiApiKeyException` | Empty/missing API key |
+| `SolapiInvalidApiKeyException` | Invalid credentials |
+| `SolapiBadRequestException` | Validation error, bad input |
+| `SolapiEmptyResponseException` | Server returned empty body |
+| `SolapiFileUploadException` | File upload failed |
+| `SolapiMessageNotReceivedException` | All messages failed (has `failedMessageList`) |
+| `SolapiUnknownException` | Unclassified server error |
+
+## KAKAO INTEGRATION
+
+19 model files in `model/kakao/`:
+- `KakaoOption` - Main config (pfId, templateId, variables)
+- `KakaoAlimtalkTemplate*` - Template CRUD models
+- `KakaoBrandMessageTemplate` - Brand message with carousels
+- `KakaoButton`, `KakaoButtonType` - Button configurations
+
+Template workflow: `getKakaoAlimtalkTemplateCategories()` → `createKakaoAlimtalkTemplate()` → `requestKakaoAlimtalkTemplateInspection()`
+
+## BUILD & TEST
+
+```bash
+./gradlew clean build test # Full build
+./gradlew test # Tests only
+./gradlew shadowJar # Fat JAR with relocated deps
+```
+
+**Shadow JAR**: Dependencies relocated to `com.solapi.shadow.*` to prevent conflicts.
+
+## NOTES
+
+- **Java 8 target**: Code must work on JVM 1.8
+- **Source location**: Kotlin in `src/main/java/` (unconventional but intentional)
+- **Tests**: `src/test/kotlin/`, `kotlin.test` (Kotlin native), Given-When-Then style
+- **Version**: Auto-generated at `build/generated/source/kotlin/com/solapi/sdk/Version.kt`
+- **Docs**: Dokka output to `./docs/`, run `./gradlew dokkaGeneratePublicationHtml`
diff --git a/CLAUDE.md b/CLAUDE.md
index f072492..62a8b35 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2,6 +2,84 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+## Development Principles
+
+### Tidy First (Kent Beck)
+
+코드 수정 및 기능 추가 시 반드시 Kent Beck의 **Tidy First** 원칙을 적용합니다.
+
+> References:
+> - https://tidyfirst.substack.com/p/augmented-coding-beyond-the-vibes
+> - https://tidyfirst.substack.com/p/taming-the-genie-like-kent-beck
+
+#### 핵심 원칙: 구조적 변경과 행위적 변경의 분리
+
+모든 코드 변경은 두 가지 유형으로 분류됩니다:
+
+| 유형 | 설명 | 예시 |
+|------|------|------|
+| **구조적 변경 (Structural)** | 동작 변경 없이 코드 구조만 개선 | 리네이밍, 메서드 추출, 파일 재구성, 중복 제거 |
+| **행위적 변경 (Behavioral)** | 실제 기능 추가 또는 수정 | 새 API 추가, 버그 수정, 로직 변경 |
+
+**절대 규칙**: 구조적 변경과 행위적 변경을 **하나의 커밋에 혼합하지 않습니다**.
+
+#### 작업 순서
+
+1. **Tidy First**: 기능 추가 전, 해당 영역의 코드를 먼저 정리
+ - 변수/함수명 명확하게 변경
+ - 복잡한 메서드 분리 (작고 특화된 클래스, 단일 책임)
+ - 중복 코드 제거
+ - 테스트 실행하여 동작 보존 확인
+ - **별도 커밋으로 분리** (예: `refactor: ...`)
+
+2. **Behavioral Change**: 정리된 코드 위에 기능 구현
+ - TDD 사이클 적용: Red → Green → Refactor
+ - 가장 단순한 실패 테스트 먼저 작성
+ - 테스트 통과를 위한 최소한의 코드 구현
+ - **별도 커밋으로 분리** (예: `feat: ...`, `fix: ...`)
+
+#### AI(Claude) 코딩 지침
+
+Kent Beck 스타일로 코드를 작성합니다:
+
+1. **페르소나 적용**: "code like Kent Beck"
+ - 모듈형 단위 테스트 작성 (모놀리식 테스트 스크립트 지양)
+ - 명확한 변수/함수 명명
+ - TDD 스타일 개발 습관
+
+2. **아키텍처 명시**
+ - 적절한 디자인 패턴 선택 및 적용
+ - 작고 특화된 클래스로 행위 분리
+ - 단일 책임 원칙 준수
+
+3. **변경 분리 필수**
+ - 구조적 변경 요청 시: 동작 변경 없이 리팩토링만 수행
+ - 기능 추가 요청 시: 필요한 경우 먼저 tidy 커밋을 분리하여 제안
+
+#### 커밋 전략
+
+```bash
+# 좋은 예 - 분리된 커밋
+git commit -m "refactor: extract validation logic to separate method"
+git commit -m "feat: add phone number format validation"
+
+# 나쁜 예 - 혼합된 커밋 (금지)
+git commit -m "feat: add validation and refactor code"
+```
+
+#### 체크리스트
+
+**기능 추가 또는 코드 수정 전:**
+- [ ] 수정할 영역에 정리가 필요한 코드가 있는가?
+- [ ] 있다면, 구조적 변경을 먼저 별도 커밋으로 완료했는가?
+- [ ] 구조적 변경 후 테스트가 통과하는가?
+
+**기능 구현 시:**
+- [ ] 테스트를 먼저 작성했는가? (TDD)
+- [ ] 최소한의 코드로 테스트를 통과시켰는가?
+- [ ] 행위적 변경만 포함된 커밋인가?
+- [ ] 클래스/메서드가 단일 책임을 가지는가?
+
## Development Commands
### Build and Test
@@ -12,6 +90,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
# Run tests only
./gradlew test
+# Run a single test
+./gradlew test --tests "ClassName.methodName"
+
# Build without tests
./gradlew build -x test
```
@@ -20,6 +101,12 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash
# Generate Dokka documentation (outputs to ./docs/)
./gradlew dokkaGeneratePublicationHtml
+
+# Generate Javadoc JAR
+./gradlew dokkaJavadocJar
+
+# Generate sources JAR
+./gradlew sourcesJar
```
### Publishing
@@ -33,6 +120,30 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Architecture
+### Package Structure
+```
+com.solapi.sdk/
+├── SolapiClient.kt # Main entry point, factory methods
+├── NurigoApp.kt # Application configuration
+└── message/
+ ├── dto/
+ │ ├── request/ # API request DTOs
+ │ │ └── kakao/ # Kakao-specific requests
+ │ └── response/ # API response DTOs
+ │ ├── common/ # Shared response types
+ │ └── kakao/ # Kakao-specific responses
+ ├── exception/ # Custom exception hierarchy
+ ├── lib/ # Utility classes (Authenticator, helpers)
+ ├── model/ # Core domain models
+ │ ├── fax/ # Fax options
+ │ ├── group/ # Group messaging models
+ │ ├── kakao/ # Kakao templates and options
+ │ ├── naver/ # Naver options
+ │ ├── rcs/ # RCS options
+ │ └── voice/ # Voice message options
+ └── service/ # Service layer implementations
+```
+
### Core Structure
- **Package Migration**: Recently migrated from `net.nurigo.sdk` to `com.solapi.sdk`
- **Main Entry Point**: `SolapiClient` object provides factory methods for creating service instances
@@ -50,6 +161,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Authenticator**: `Authenticator.kt` handles HMAC-based API authentication
- **Auto-injection**: Authentication headers are automatically added via OkHttp interceptor
+#### Exception Hierarchy
+- `SolapiException` - Base exception class
+ - `SolapiApiKeyException` - API key related errors
+ - `SolapiInvalidApiKeyException` - Invalid API key
+ - `SolapiBadRequestException` - Bad request errors
+ - `SolapiEmptyResponseException` - Empty response from server
+ - `SolapiFileUploadException` - File upload failures
+ - `SolapiMessageNotReceivedException` - Message delivery failures
+ - `SolapiUnknownException` - Unclassified errors
+
#### Specialized Features
- **Kakao Integration**: Full support for Alimtalk and Brand Message templates
- **File Upload**: Base64 encoding for MMS, Fax, and other file-based messages
@@ -64,16 +185,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Build Configuration
- **Target**: Java 8 compatibility
-- **Kotlin**: Version 2.2.0 with coroutines and serialization
-- **Dependencies**: OkHttp, Retrofit, Kotlinx Serialization, Apache Commons Codec
+- **Kotlin**: Version 2.2.10 with kotlinx-serialization
+- **Gradle**: Version 8.14.3 (via wrapper)
+- **Dependencies**: OkHttp 5.1.0, Retrofit 3.0.0, Kotlinx Serialization 1.9.0, Apache Commons Codec 1.18.0
- **Shadow JAR**: Dependencies relocated to `com.solapi.shadow` namespace
-- **Version Generation**: Build script auto-generates `Version.kt` with current version
+- **Version Generation**: Build script auto-generates `Version.kt` at `build/generated/source/kotlin/com/solapi/sdk/Version.kt`
## Testing
-- **Framework**: JUnit 5 Jupiter
+- **Framework**: JUnit 5 Jupiter (configured but no tests written yet)
- **Run Command**: `./gradlew test`
-- **Test Location**: `src/test/java/`
+- **Test Location**: `src/test/java/` (to be created)
## Documentation
- **API Docs**: Generated with Dokka to `./docs/` directory
-- **Examples**: Referenced external repository for usage examples
\ No newline at end of file
+- **Examples**: Referenced external repository for usage examples
diff --git a/LLM_GUIDE.md b/LLM_GUIDE.md
new file mode 100644
index 0000000..13ce5c5
--- /dev/null
+++ b/LLM_GUIDE.md
@@ -0,0 +1,305 @@
+# SOLAPI Kotlin/Java SDK - LLM Guide
+
+Technical guide for LLM agents writing code using the SDK.
+
+## Agent Workflow
+
+LLM agents should use the AskUserQuestion tool to ask users about their messaging needs before writing code.
+
+### Required Questions
+
+Ask the user the following questions using AskUserQuestion:
+
+1. **Message Type** - Which type of message do you want to send?
+ - SMS/LMS (Text message)
+ - MMS (Image message)
+ - Kakao Alimtalk (Notification)
+ - Kakao Brand Message (Freeform/Template)
+ - RCS
+ - Voice
+ - FAX
+
+2. **Programming Language** - Which language are you using?
+ - Kotlin
+ - Java
+
+3. **Send Mode** - How do you want to send messages?
+ - Single message
+ - Batch send (multiple recipients)
+ - Scheduled send
+
+After gathering requirements, proceed to the relevant sections in this guide.
+
+## Quick Reference
+
+| Item | Value |
+|------|-------|
+| SDK | `com.solapi:sdk:1.1.0` |
+| Docs | https://developers.solapi.com/llms.txt |
+| API Ref | https://solapi.github.io/solapi-kotlin/ |
+| Java | 8+ |
+| Kotlin | 1.8+ |
+
+## JDK 8 Note
+
+JDK 8 does not support `Map.of()`, `List.of()`. Use HashMap, ArrayList instead:
+```java
+Map variables = new HashMap<>();
+variables.put("name", "홍길동");
+```
+
+## Setup
+
+**Gradle (Kotlin DSL):**
+```kotlin
+implementation("com.solapi:sdk:1.1.0")
+```
+
+**Maven:**
+```xml
+
+ com.solapi
+ sdk
+ 1.1.0
+
+```
+
+## Core Pattern
+
+**Kotlin:**
+```kotlin
+val service = SolapiClient.createInstance("API_KEY", "API_SECRET")
+val message = Message(from = "발신번호", to = "수신번호", text = "내용")
+val response = service.send(message)
+```
+
+**Java:**
+```java
+DefaultMessageService service = SolapiClient.INSTANCE.createInstance("API_KEY", "API_SECRET");
+Message message = new Message();
+message.setFrom("발신번호");
+message.setTo("수신번호");
+message.setText("내용");
+service.send(message, null);
+```
+
+## Message Types
+
+| Type | Field | Notes |
+|------|-------|-------|
+| SMS | text | < 80 bytes |
+| LMS | text | 80-2000 bytes |
+| MMS | imageId | Use after uploadFile(), JPG/JPEG, max 200KB |
+| ATA | kakaoOptions | Kakao Alimtalk, template required |
+| BMS_* | kakaoOptions | Kakao Brand Message |
+| RCS_* | rcsOptions | RCS message |
+| NSA | naverOptions | Naver Smart Notification |
+| FAX | fileId | Use after uploadFile() |
+| VOICE | voiceOptions | Voice message |
+
+## KakaoOption
+
+```kotlin
+KakaoOption(
+ pfId = "카카오채널ID",
+ templateId = "템플릿ID",
+ variables = mapOf("name" to "홍길동", "code" to "123456")
+)
+```
+
+```java
+KakaoOption kakaoOption = new KakaoOption();
+kakaoOption.setPfId("카카오채널ID");
+kakaoOption.setTemplateId("템플릿ID");
+Map variables = new HashMap<>();
+variables.put("name", "홍길동");
+variables.put("code", "123456");
+kakaoOption.setVariables(variables);
+```
+
+## File Upload (MMS)
+
+**Kotlin:**
+```kotlin
+val imageId = service.uploadFile(file, StorageType.MMS)
+val message = Message(
+ type = MessageType.MMS,
+ from = "발신번호",
+ to = "수신번호",
+ text = "MMS 내용",
+ subject = "제목",
+ imageId = imageId
+)
+service.send(message)
+```
+
+**Java:**
+```java
+String imageId = service.uploadFile(file, StorageType.MMS, null);
+Message message = new Message();
+message.setType(MessageType.MMS);
+message.setFrom("발신번호");
+message.setTo("수신번호");
+message.setText("MMS 내용");
+message.setSubject("제목");
+message.setImageId(imageId);
+service.send(message, null);
+```
+
+## Batch Send
+
+**Kotlin:**
+```kotlin
+val messages = recipients.map { recipient ->
+ Message(from = sender, to = recipient, text = "메시지")
+}
+service.send(messages) // max 10,000
+
+// 중복 번호 허용
+val config = SendRequestConfig().apply { allowDuplicates = true }
+service.send(messages, config)
+```
+
+**Java:**
+```java
+List messages = new ArrayList<>();
+for (String recipient : recipients) {
+ Message msg = new Message();
+ msg.setFrom(sender);
+ msg.setTo(recipient);
+ msg.setText("메시지");
+ messages.add(msg);
+}
+service.send(messages, null); // max 10,000
+
+// 중복 번호 허용
+SendRequestConfig config = new SendRequestConfig();
+config.setAllowDuplicates(true);
+service.send(messages, config);
+```
+
+## Scheduled Send
+
+**Kotlin:**
+```kotlin
+val config = SendRequestConfig().apply {
+ setScheduledDateFromLocalDateTime(
+ LocalDateTime.now().plusMinutes(10),
+ ZoneId.of("Asia/Seoul")
+ )
+}
+service.send(message, config)
+```
+
+**Java:**
+```java
+SendRequestConfig config = new SendRequestConfig();
+config.setScheduledDateFromLocalDateTime(
+ LocalDateTime.now().plusMinutes(10),
+ ZoneId.of("Asia/Seoul")
+);
+service.send(message, config);
+```
+
+Constraints: Minimum 10 minutes, maximum 6 months in advance
+
+## API Methods
+
+**Send:**
+| Method | Description |
+|--------|-------------|
+| `send(message)` | Send single message |
+| `send(messages)` | Send multiple messages (max 10,000) |
+| `send(message, config)` | Send with configuration |
+| `uploadFile(file, type)` | Upload file |
+
+**Query:**
+| Method | Description |
+|--------|-------------|
+| `getBalance()` | Get balance |
+| `getQuota()` | Get daily quota |
+| `getMessageList(request)` | Get message history |
+
+**Kakao Templates:**
+| Method | Description |
+|--------|-------------|
+| `getKakaoAlimtalkTemplates()` | Get Alimtalk template list |
+| `getKakaoAlimtalkTemplate(id)` | Get template details |
+| `getSendableKakaoAlimtalkTemplates()` | Get sendable templates |
+
+## Exceptions
+
+| Exception | Cause |
+|-----------|-------|
+| `SolapiBadRequestException` | Invalid parameters |
+| `SolapiInvalidApiKeyException` | API key error |
+| `SolapiApiKeyException` | API key related error |
+| `SolapiFileUploadException` | File upload failed |
+| `SolapiMessageNotReceivedException` | Send failed |
+| `SolapiEmptyResponseException` | Empty response |
+| `SolapiUnknownException` | Unknown error |
+
+## Error Handling Pattern
+
+**Kotlin:**
+```kotlin
+try {
+ service.send(message)
+} catch (e: SolapiBadRequestException) {
+ // 잘못된 요청
+} catch (e: SolapiInvalidApiKeyException) {
+ // API 키 오류
+} catch (e: SolapiMessageNotReceivedException) {
+ // 발송 실패
+} catch (e: SolapiException) {
+ // 기타 오류
+}
+```
+
+**Java:**
+```java
+try {
+ service.send(message, null);
+} catch (SolapiBadRequestException e) {
+ // 잘못된 요청
+} catch (SolapiInvalidApiKeyException e) {
+ // API 키 오류
+} catch (SolapiMessageNotReceivedException e) {
+ // 발송 실패
+} catch (SolapiException e) {
+ // 기타 오류
+}
+```
+
+## Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `SOLAPI_API_KEY` | API key |
+| `SOLAPI_API_SECRET` | API secret |
+| `SOLAPI_SENDER` | Sender number |
+| `SOLAPI_KAKAO_PF_ID` | Kakao channel ID |
+| `SOLAPI_KAKAO_TEMPLATE_ID` | Alimtalk template ID |
+
+## Imports
+
+**Kotlin:**
+```kotlin
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.StorageType
+import com.solapi.sdk.message.model.SendRequestConfig
+import com.solapi.sdk.message.model.kakao.KakaoOption
+```
+
+**Java:**
+```java
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageType;
+import com.solapi.sdk.message.model.StorageType;
+import com.solapi.sdk.message.model.SendRequestConfig;
+import com.solapi.sdk.message.model.kakao.KakaoOption;
+import com.solapi.sdk.message.service.DefaultMessageService;
+```
diff --git a/README.md b/README.md
index 35406bb..477b3e6 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,377 @@
-# SOLAPI Kotlin(includes Java) SDK
+# SOLAPI Kotlin/Java SDK
-### 실행방법
+[](https://central.sonatype.com/artifact/com.solapi/sdk)
+[](https://opensource.org/licenses/MIT)
----
+Kotlin과 Java에서 SOLAPI 메시지 발송 서비스를 사용할 수 있는 공식 SDK입니다.
-### 메시지 발송
+## AI와 함께 시작하기
+
+### 사람이 읽을 가이드:
+Claude Code, Cursor, Windsurf 등 AI 코딩 도구에 아래 프롬프트를 붙여넣기하세요:
+
+```text
+Implement messaging feature using SOLAPI SDK.
+Guide: https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md
+```
+
+### LLM 에이전트를 위한 가이드:
+SDK 가이드를 가져온 후 LLM 가이드를 따르세요.
+
+```bash
+curl -s https://raw.githubusercontent.com/solapi/solapi-kotlin/main/LLM_GUIDE.md
+```
+
+| 상황 | 프롬프트 |
+|------|----------|
+| SMS/MMS 발송 | "SOLAPI SDK로 SMS 발송 코드 작성해줘" |
+| 카카오 알림톡 | "SOLAPI SDK로 카카오 알림톡 발송 구현해줘" |
+| 대량 발송 | "SOLAPI SDK로 대량 문자 발송 기능 추가해줘" |
+| 예약 발송 | "SOLAPI SDK로 예약 발송 구현해줘" |
+| 발송 조회 | "SOLAPI SDK로 발송 결과 조회 코드 작성해줘" |
+
+## 설치
+
+### Gradle (Kotlin DSL)
+
+```kotlin
+dependencies {
+ implementation("com.solapi:sdk:1.1.0")
+}
+```
+
+### Gradle (Groovy)
+
+```groovy
+dependencies {
+ implementation 'com.solapi:sdk:1.1.0'
+}
+```
+
+### Maven
+
+```xml
+
+ com.solapi
+ sdk
+ 1.1.0
+
+```
+
+## 빠른 시작
+
+### Java
-#### 자바 기준
```java
-DefaultMessageService solapiClient = SolapiClient.INSTANCE.createInstance("ENTER_YOUR_API_KEY", "ENTER_YOUR_API_SECRET_KEY");
-Message message = new Message();
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+public class Main {
+ public static void main(String[] args) {
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance("API_KEY", "API_SECRET");
+
+ Message message = new Message();
+ message.setFrom("발신번호");
+ message.setTo("수신번호");
+ message.setText("안녕하세요. SOLAPI SDK 테스트입니다.");
+
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+ }
+}
+```
+
+### Kotlin
+
+```kotlin
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+
+fun main() {
+ val messageService = SolapiClient.createInstance("API_KEY", "API_SECRET")
+
+ val message = Message(
+ from = "발신번호",
+ to = "수신번호",
+ text = "안녕하세요. SOLAPI SDK 테스트입니다."
+ )
+
+ val response = messageService.send(message)
+ println("Group ID: ${response.groupInfo?.groupId}")
+}
+```
+
+## 예제 실행
+
+### 환경변수 설정
+
+| 환경변수 | 설명 | 필수 |
+|----------|------|:---------------:|
+| `SOLAPI_API_KEY` | SOLAPI API 키 | O |
+| `SOLAPI_API_SECRET` | SOLAPI API 시크릿 | O |
+| `SOLAPI_SENDER` | 등록된 발신번호 | O |
+| `SOLAPI_RECIPIENT` | 수신번호 | O |
+| `SOLAPI_KAKAO_PF_ID` | 카카오 비즈니스 채널 ID | 카카오 계열 메시지 발송 시 |
+| `SOLAPI_KAKAO_TEMPLATE_ID` | 카카오 알림톡 템플릿 ID | 알림톡 발송 시 |
+
+### 실행 명령어
+
+```bash
+# Java 예제 실행
+./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms
+
+# Kotlin 예제 실행
+./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms
+```
+
+### JDK 8 사용자 안내
+
+**SDK 사용 시**: 본 SDK는 JDK 8 이상에서 정상 동작합니다. Maven Central에서 의존성을 추가하면 JDK 8 환경에서 바로 사용 가능합니다.
+
+**예제 실행 시**: 이 저장소의 예제를 직접 실행하려면 Gradle 9.x가 필요하며, 이는 **JDK 21 이상**을 요구합니다.
+
+JDK 8만 설치된 환경에서 예제를 실행하려면:
+
+#### 방법 1: 별도의 JDK 21+ 설치 (권장)
+
+Gradle Toolchain이 자동으로 JDK 8을 다운로드하여 예제를 컴파일합니다. Gradle 실행용으로만 JDK 21 이상이 필요합니다.
+
+```bash
+# macOS (Homebrew)
+brew install openjdk@21
+
+# Ubuntu/Debian
+sudo apt install openjdk-21-jdk
+
+# SDKMAN (권장)
+sdk install java 21.0.2-tem
+```
+
+#### 방법 2: 자신의 프로젝트에서 직접 테스트
+
+JDK 8 환경의 자체 프로젝트에서 SDK를 추가하고 테스트할 수 있습니다:
+
+```java
+// build.gradle (Groovy)
+plugins {
+ id 'java'
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+}
+dependencies {
+ implementation 'com.solapi:sdk:1.1.0'
+}
+```
+
+```java
+// src/main/java/MyTest.java
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+public class MyTest {
+ public static void main(String[] args) {
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(
+ "YOUR_API_KEY",
+ "YOUR_API_SECRET"
+ );
+
+ Message message = new Message();
+ message.setFrom("발신번호");
+ message.setTo("수신번호");
+ message.setText("테스트 메시지");
+
+ messageService.send(message, null);
+ }
+}
+```
+
+> **참고**: Gradle 7.x (JDK 11+) 또는 Gradle 6.x (JDK 8+)를 사용하는 프로젝트에서는 위 설정으로 바로 사용 가능합니다.
+
+### 예제 목록
+
+| 예제 | 설명 |
+|------|------|
+| `SendSms` | SMS 단건 발송 |
+| `SendMms` | MMS 이미지 첨부 발송 |
+| `SendBatch` | 대량 메시지 발송 |
+| `SendScheduled` | 예약 발송 |
+| `SendVoice` | 음성 메시지 발송 |
+| `KakaoAlimtalk` | 카카오 알림톡 발송 |
+| `KakaoBrandMessage` | 카카오 브랜드 메시지 발송 |
+| `GetBalance` | 잔액 조회 |
+| `GetMessageList` | 발송 내역 조회 |
+
+## 지원 메시지 타입
+
+| 타입 | 설명 |
+|------|---------------------------------|
+| `SMS` | 단문문자 (80 byte 미만) |
+| `LMS` | 장문문자 (80 byte 이상, 2,000 byte 미만) |
+| `MMS` | 이미지 포함 문자 (200KB 이내 이미지 1장) |
+| `ATA` | 카카오 알림톡 |
+| `BMS_*` | 카카오 브랜드 메시지 (템플릿, 자유형) |
+| `RCS_*` | RCS 문자 (SMS, LMS, MMS, TPL) |
+| `NSA` | 네이버 스마트 알림 |
+| `FAX` | 팩스 |
+| `VOICE` | 음성 메시지 |
+
+## 주요 기능
+
+### MMS 이미지 첨부 발송
+
+**Java:**
+```java
+// 이미지 업로드
+String imageId = messageService.uploadFile(imageFile, StorageType.MMS, null);
+
+// MMS 메시지 생성 및 발송
+Message message = new Message();
+message.setType(MessageType.MMS);
message.setFrom("발신번호");
message.setTo("수신번호");
-message.setText("메시지 내용");
+message.setText("MMS 메시지 내용");
+message.setSubject("MMS 제목");
+message.setImageId(imageId);
+messageService.send(message, null);
+```
+
+**이미지 규격:** JPG/JPEG, 최대 200KB, 권장 해상도 1000x1000 이하
+
+### 대량 메시지 발송
-System.out.println(solapiClient.send(message));
+**Java:**
+```java
+List messages = new ArrayList<>();
+for (int i = 1; i <= 100; i++) {
+ Message msg = new Message();
+ msg.setFrom(sender);
+ msg.setTo("010XXXX000" + i);
+ msg.setText("메시지 " + i);
+ messages.add(msg);
+}
+
+// 중복 수신번호 허용 옵션
+SendRequestConfig config = new SendRequestConfig();
+config.setAllowDuplicates(true);
+messageService.send(messages, config);
```
-#### 코틀린 기준
-```kotlin
-val solapiClient = SolapiClient.createInstance("ENTER_YOUR_API_KEY", "ENTER_YOUR_API_SECRET_KEY")
+- 한 번에 최대 **10,000건** 발송 가능
+- `allowDuplicates = true`로 동일 수신번호 중복 발송 허용
-val message = Message(from = "발신번호", to = "수신번호", text = "메시지 내용")
+### 예약 발송
-println(solapiClient.send(message))
+**Java:**
+```java
+SendRequestConfig config = new SendRequestConfig();
+config.setScheduledDateFromLocalDateTime(
+ LocalDateTime.now().plusMinutes(10),
+ ZoneId.of("Asia/Seoul")
+);
+messageService.send(message, config);
```
-더 자세한 사용 방법 및 예제는 [SOLAPI SDK 예제 레포지터리](https://github.com/solapi/solapi-java-examples)를 참고 해주세요.
+- 최소 **10분 후**부터 최대 **6개월 이내** 예약 가능
+- 과거 시간 지정 시 즉시 발송 처리
+
+### 카카오 알림톡
+
+**Java:**
+```java
+Map variables = new HashMap<>();
+variables.put("name", "홍길동");
+variables.put("code", "123456");
+
+KakaoOption kakaoOption = new KakaoOption();
+kakaoOption.setPfId("카카오채널ID");
+kakaoOption.setTemplateId("템플릿ID");
+kakaoOption.setVariables(variables);
+
+Message message = new Message();
+message.setType(MessageType.ATA);
+message.setFrom("발신번호");
+message.setTo("수신번호");
+message.setKakaoOptions(kakaoOption);
+messageService.send(message, null);
+```
+
+- 검수 승인된 템플릿만 사용 가능
+- 정보성 메시지 전용 (광고 불가)
+
+## API 레퍼런스
+
+### 메시지 발송
+
+| 메서드 | 설명 |
+|--------|------|
+| `send(message)` | 단건 메시지 발송 |
+| `send(messages)` | 다건 메시지 발송 (최대 10,000건) |
+| `send(message, config)` | 설정과 함께 발송 (예약, 중복 허용 등) |
+| `uploadFile(file, type)` | 파일 업로드 (MMS, FAX 등) |
+
+### 조회
+
+| 메서드 | 설명 |
+|--------|------|
+| `getBalance()` | 잔액 조회 |
+| `getQuota()` | 일일 발송량 한도 조회 |
+| `getMessageList(request)` | 메시지 발송 내역 조회 |
+
+### 카카오 템플릿 관리
+
+| 메서드 | 설명 |
+|--------|------|
+| `getKakaoAlimtalkTemplates()` | 알림톡 템플릿 목록 조회 |
+| `getKakaoAlimtalkTemplate(id)` | 알림톡 템플릿 상세 조회 |
+| `createKakaoAlimtalkTemplate(request)` | 알림톡 템플릿 생성 |
+| `getSendableKakaoAlimtalkTemplates()` | 발송 가능한 템플릿 조회 |
+| `getKakaoBrandMessageTemplates()` | 브랜드 메시지 템플릿 조회 |
+
+## 에러 처리
+
+```java
+try {
+ messageService.send(message, null);
+} catch (SolapiBadRequestException e) {
+ System.out.println("잘못된 요청: " + e.getMessage());
+} catch (SolapiInvalidApiKeyException e) {
+ System.out.println("잘못된 API 키: " + e.getMessage());
+} catch (SolapiMessageNotReceivedException e) {
+ System.out.println("발송 실패: " + e.getMessage());
+} catch (SolapiException e) {
+ System.out.println("기타 오류: " + e.getMessage());
+}
+```
+
+| 예외 클래스 | 설명 |
+|-------------|------|
+| `SolapiBadRequestException` | 잘못된 요청 파라미터 |
+| `SolapiInvalidApiKeyException` | 유효하지 않은 API 키 |
+| `SolapiApiKeyException` | API 키 관련 오류 |
+| `SolapiFileUploadException` | 파일 업로드 실패 |
+| `SolapiMessageNotReceivedException` | 메시지 수신 실패 |
+| `SolapiEmptyResponseException` | 빈 응답 수신 |
+| `SolapiUnknownException` | 알 수 없는 오류 |
+
+## 요구 사항
+
+- **Java 8** 이상
+- **Kotlin 2.2.0** 이상 (Kotlin 사용 시)
+
+## 관련 링크
+
+- [SOLAPI 개발연동 문서](https://developers.solapi.com)
+- [API 키 발급](https://console.solapi.com/credentials)
+- [발신번호 등록](https://console.solapi.com/senderids)
+- [API Reference (Dokka)](https://solapi.github.io/solapi-kotlin/)
+
+## 라이선스
+
+MIT License - 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
diff --git a/build.gradle.kts b/build.gradle.kts
index 0b27823..1794833 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,28 +1,33 @@
import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven
-import org.jetbrains.dokka.gradle.DokkaTaskPartial
import org.jetbrains.dokka.gradle.tasks.DokkaGeneratePublicationTask
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
- kotlin("jvm") version "2.2.10"
- kotlin("plugin.serialization") version "2.2.10"
- id("org.jetbrains.dokka") version "2.0.0"
- id("com.gradleup.shadow") version "8.3.8"
+ kotlin("jvm") version "2.3.0"
+ kotlin("plugin.serialization") version "2.3.0"
+ id("org.jetbrains.dokka") version "2.1.0"
+ id("com.gradleup.shadow") version "9.3.1"
java
`java-library`
`maven-publish`
signing
- id("com.vanniktech.maven.publish") version "0.34.0"
+ id("com.vanniktech.maven.publish") version "0.36.0"
}
group = "com.solapi"
-version = "1.0.3"
+version = "1.1.0"
repositories {
mavenCentral()
}
+dokka {
+ dokkaPublications.html {
+ suppressInheritedMembers.set(true)
+ }
+}
+
mavenPublishing {
// Central Portal 사용 (OSSRH가 아닌)
publishToMavenCentral(automaticRelease = false)
@@ -77,17 +82,16 @@ mavenPublishing {
dependencies {
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
- implementation("commons-codec:commons-codec:1.18.0")
- implementation("com.squareup.okhttp3:okhttp:5.1.0")
- implementation("com.squareup.okhttp3:logging-interceptor:5.1.0")
+ implementation("commons-codec:commons-codec:1.20.0")
+ implementation("com.squareup.okhttp3:okhttp:5.3.0")
+ implementation("com.squareup.okhttp3:logging-interceptor:5.3.0")
implementation("com.squareup.retrofit2:retrofit:3.0.0")
- implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation("com.squareup.retrofit2:converter-kotlinx-serialization:3.0.0")
- testImplementation("org.junit.jupiter:junit-jupiter-api:5.10.0")
- testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.10.0")
+ testImplementation(kotlin("test"))
- dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:2.0.0")
+ dokkaHtmlPlugin("org.jetbrains.dokka:kotlin-as-java-plugin:2.1.0")
}
val generatedSrcDir = layout.buildDirectory.dir("generated/source/kotlin")
@@ -110,6 +114,9 @@ val generateVersionFile by tasks.register("generateVersionFile") {
tasks.withType().configureEach {
dependsOn(generateVersionFile)
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_1_8)
+ }
}
java {
@@ -124,12 +131,12 @@ tasks.named("sourcesJar") {
tasks.shadowJar {
mergeServiceFiles()
- // 의존성 충돌을 피하기 위해 필요한 패키지만 relocate
relocate("com.fasterxml", "com.solapi.shadow.com.fasterxml")
relocate("okhttp3", "com.solapi.shadow.okhttp3")
relocate("okio", "com.solapi.shadow.okio")
relocate("retrofit2", "com.solapi.shadow.retrofit2")
relocate("org.apache", "com.solapi.shadow.org.apache")
+ relocate("kotlinx.serialization", "com.solapi.shadow.kotlinx.serialization")
archiveClassifier.set("")
}
@@ -144,20 +151,6 @@ tasks.withType().configureEach {
})
}
-val compileKotlin: KotlinCompile by tasks
-compileKotlin.compilerOptions {
- jvmTarget.set(JvmTarget.JVM_1_8)
-}
-
-val compileTestKotlin: KotlinCompile by tasks
-compileTestKotlin.compilerOptions {
- jvmTarget.set(JvmTarget.JVM_1_8)
-}
-
-tasks.withType().configureEach {
- outputDirectory.set(project.rootDir.resolve("docs"))
-}
-
tasks.withType().configureEach {
dependsOn(generateVersionFile)
outputDirectory.set(project.rootDir.resolve("docs"))
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 7454180..1b33c55 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 3ae1e2f..19a6bde 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 744e882..23d15a9 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
-#!/usr/bin/env sh
+#!/bin/sh
#
-# Copyright 2015 the original author or authors.
+# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,81 +15,115 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
-##
-## Gradle start up script for UN*X
-##
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
##############################################################################
# Attempt to set APP_HOME
+
# Resolve links: $0 may be a link
-PRG="$0"
-# Need this for relative symlinks.
-while [ -h "$PRG" ] ; do
- ls=`ls -ld "$PRG"`
- link=`expr "$ls" : '.*-> \(.*\)$'`
- if expr "$link" : '/.*' > /dev/null; then
- PRG="$link"
- else
- PRG=`dirname "$PRG"`"/$link"
- fi
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
done
-SAVED="`pwd`"
-cd "`dirname \"$PRG\"`/" >/dev/null
-APP_HOME="`pwd -P`"
-cd "$SAVED" >/dev/null
-APP_NAME="Gradle"
-APP_BASE_NAME=`basename "$0"`
-
-# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
-DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
-MAX_FD="maximum"
+MAX_FD=maximum
warn () {
echo "$*"
-}
+} >&2
die () {
echo
echo "$*"
echo
exit 1
-}
+} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
-case "`uname`" in
- CYGWIN* )
- cygwin=true
- ;;
- Darwin* )
- darwin=true
- ;;
- MSYS* | MINGW* )
- msys=true
- ;;
- NONSTOP* )
- nonstop=true
- ;;
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
- JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACMD=$JAVA_HOME/jre/sh/java
else
- JAVACMD="$JAVA_HOME/bin/java"
+ JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
@@ -98,88 +132,120 @@ Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
- JAVACMD="java"
- which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
+ fi
fi
# Increase the maximum file descriptors if we can.
-if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
- MAX_FD_LIMIT=`ulimit -H -n`
- if [ $? -eq 0 ] ; then
- if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
- MAX_FD="$MAX_FD_LIMIT"
- fi
- ulimit -n $MAX_FD
- if [ $? -ne 0 ] ; then
- warn "Could not set maximum file descriptor limit: $MAX_FD"
- fi
- else
- warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
- fi
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
fi
-# For Darwin, add options to specify how the application appears in the dock
-if $darwin; then
- GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
-fi
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
-if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
- APP_HOME=`cygpath --path --mixed "$APP_HOME"`
- CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
-
- JAVACMD=`cygpath --unix "$JAVACMD"`
-
- # We build the pattern for arguments to be converted via cygpath
- ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
- SEP=""
- for dir in $ROOTDIRSRAW ; do
- ROOTDIRS="$ROOTDIRS$SEP$dir"
- SEP="|"
- done
- OURCYGPATTERN="(^($ROOTDIRS))"
- # Add a user-defined pattern to the cygpath arguments
- if [ "$GRADLE_CYGPATTERN" != "" ] ; then
- OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
- fi
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
# Now convert the arguments - kludge to limit ourselves to /bin/sh
- i=0
- for arg in "$@" ; do
- CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
- CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
-
- if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
- eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
- else
- eval `echo args$i`="\"$arg\""
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
fi
- i=`expr $i + 1`
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
done
- case $i in
- 0) set -- ;;
- 1) set -- "$args0" ;;
- 2) set -- "$args0" "$args1" ;;
- 3) set -- "$args0" "$args1" "$args2" ;;
- 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
- 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
- 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
- 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
- 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
- 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
- esac
fi
-# Escape application args
-save () {
- for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
- echo " "
-}
-APP_ARGS=`save "$@"`
-# Collect all arguments for the java command, following the shell quoting and substitution rules
-eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
index 107acd3..db3a6ac 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,8 +13,10 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
-@if "%DEBUG%" == "" @echo off
+@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@@ -25,7 +27,8 @@
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
-if "%DIRNAME%" == "" set DIRNAME=.
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@@ -40,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto execute
+if %ERRORLEVEL% equ 0 goto execute
-echo.
-echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -56,32 +59,34 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-echo.
-echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
-echo.
-echo Please set the JAVA_HOME variable in your environment to match the
-echo location of your Java installation.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+set CLASSPATH=
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
-if "%ERRORLEVEL%"=="0" goto mainEnd
+if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
-if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
-exit /b 1
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 8807792..65cea11 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1 +1,17 @@
rootProject.name = "solapi.sdk"
+
+// Example modules auto-discovery
+// - publish/release 태스크 실행 시 자동으로 제외
+// - -PskipExamples=true 로 명시적 제외 가능
+val isPublishTask = gradle.startParameter.taskNames.any {
+ it.contains("publish", ignoreCase = true) ||
+ it.contains("Release", ignoreCase = true)
+}
+val skipExamples = providers.gradleProperty("skipExamples").orNull?.toBoolean() ?: isPublishTask
+
+if (!skipExamples) {
+ rootDir.listFiles()
+ ?.filter { it.isDirectory && it.name.startsWith("solapi-kotlin-example") }
+ ?.filter { File(it, "build.gradle.kts").exists() }
+ ?.forEach { include(":${it.name}") }
+}
diff --git a/solapi-kotlin-example-java/build.gradle.kts b/solapi-kotlin-example-java/build.gradle.kts
new file mode 100644
index 0000000..95b1dff
--- /dev/null
+++ b/solapi-kotlin-example-java/build.gradle.kts
@@ -0,0 +1,34 @@
+plugins {
+ java
+ application
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation(rootProject)
+}
+
+java {
+ toolchain {
+ languageVersion.set(JavaLanguageVersion.of(8))
+ }
+}
+
+application {
+ val example = project.findProperty("example") as String? ?: "Main"
+ mainClass.set("com.solapi.example.${example}Example")
+}
+
+tasks.named("run") {
+ standardInput = System.`in`
+}
+
+// Disable distTar/distZip/installDist/startScripts from regular builds
+// These are only needed when explicitly building distributions
+tasks.named("distTar") { enabled = false }
+tasks.named("distZip") { enabled = false }
+tasks.named("installDist") { enabled = false }
+tasks.named("startScripts") { enabled = false }
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java
new file mode 100644
index 0000000..419ef5f
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetBalanceExample.java
@@ -0,0 +1,50 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.model.Balance;
+import com.solapi.sdk.message.model.Quota;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+/**
+ * 잔액 조회 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ */
+public class GetBalanceExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ try {
+ // 잔액 조회
+ Balance balance = messageService.getBalance();
+
+ System.out.println("=== 잔액 정보 ===");
+ System.out.println("Balance: " + balance.getBalance());
+ System.out.println("Point: " + balance.getPoint());
+
+ // 일일 발송량 한도 조회
+ Quota quota = messageService.getQuota();
+
+ System.out.println();
+ System.out.println("=== 일일 발송량 한도 ===");
+ System.out.println("Quota: " + quota);
+
+ } catch (Exception e) {
+ System.err.println("조회 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java
new file mode 100644
index 0000000..6faf63f
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/GetMessageListExample.java
@@ -0,0 +1,114 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.request.MessageListRequest;
+import com.solapi.sdk.message.dto.response.MessageListResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageStatusType;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.time.LocalDateTime;
+import java.util.Map;
+
+/**
+ * 발송 내역 조회 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ *
+ * 다양한 필터 옵션:
+ * - from: 발신번호
+ * - to: 수신번호
+ * - type: 메시지 타입 (SMS, LMS, MMS, ATA 등)
+ * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED)
+ * - startDate/endDate: 날짜 범위
+ */
+public class GetMessageListExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ try {
+ // 1. 기본 조회 (최근 메시지)
+ System.out.println("=== 기본 메시지 목록 (최근 10건) ===");
+ MessageListRequest basicRequest = new MessageListRequest();
+ basicRequest.setLimit(10);
+
+ MessageListResponse basicResponse = messageService.getMessageList(basicRequest);
+ printMessageList(basicResponse);
+
+ // 2. 발송 완료 메시지 조회
+ System.out.println("\n=== 발송 완료 메시지 ===");
+ MessageListRequest completedRequest = new MessageListRequest();
+ completedRequest.setStatus(MessageStatusType.COMPLETE);
+ completedRequest.setLimit(5);
+
+ MessageListResponse completedResponse = messageService.getMessageList(completedRequest);
+ printMessageList(completedResponse);
+
+ // 3. 날짜 범위 조회 (최근 7일)
+ System.out.println("\n=== 최근 7일간 메시지 ===");
+ MessageListRequest dateRequest = new MessageListRequest();
+ dateRequest.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7));
+ dateRequest.setEndDateFromLocalDateTime(LocalDateTime.now());
+ dateRequest.setLimit(5);
+
+ MessageListResponse dateResponse = messageService.getMessageList(dateRequest);
+ printMessageList(dateResponse);
+
+ // 4. SMS 타입만 조회
+ System.out.println("\n=== SMS 타입 메시지 ===");
+ MessageListRequest smsRequest = new MessageListRequest();
+ smsRequest.setType("SMS");
+ smsRequest.setLimit(5);
+
+ MessageListResponse smsResponse = messageService.getMessageList(smsRequest);
+ printMessageList(smsResponse);
+
+ } catch (Exception e) {
+ System.err.println("메시지 목록 조회 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ private static void printMessageList(MessageListResponse response) {
+ if (response == null || response.getMessageList() == null) {
+ System.out.println(" (조회 결과 없음)");
+ return;
+ }
+
+ Map messageList = response.getMessageList();
+ System.out.println(" 조회 건수: " + messageList.size());
+
+ int count = 0;
+ for (Map.Entry entry : messageList.entrySet()) {
+ if (count >= 3) {
+ System.out.println(" ... (외 " + (messageList.size() - 3) + "건)");
+ break;
+ }
+
+ Message msg = entry.getValue();
+ System.out.println(" - ID: " + entry.getKey());
+ System.out.println(" Type: " + msg.getType() + ", To: " + msg.getTo());
+ System.out.println(" Status: " + msg.getStatusCode() + ", Text: " + truncate(msg.getText(), 30));
+ count++;
+ }
+ }
+
+ private static String truncate(String str, int maxLength) {
+ if (str == null) return "(null)";
+ if (str.length() <= maxLength) return str;
+ return str.substring(0, maxLength) + "...";
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java
new file mode 100644
index 0000000..362f62e
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoAlimtalkExample.java
@@ -0,0 +1,87 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageType;
+import com.solapi.sdk.message.model.kakao.KakaoOption;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 카카오 알림톡 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID
+ *
+ * 알림톡 특징:
+ * - 사전에 검수 승인된 템플릿만 사용 가능
+ * - 정보성 메시지 전용 (광고 불가)
+ * - 변수 치환을 통해 동적 내용 전달 가능
+ * 번외:
+ * 브랜드 메시지 템플릿과 동일한 형태의 코드로 발송하실 수 있습니다!
+ */
+public class KakaoAlimtalkExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+ String pfId = System.getenv("SOLAPI_KAKAO_PF_ID");
+ String templateId = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+ if (pfId == null || templateId == null) {
+ System.err.println("SOLAPI_KAKAO_PF_ID and SOLAPI_KAKAO_TEMPLATE_ID must be set for Kakao Alimtalk");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 템플릿 변수 설정 (템플릿에 맞게 조정 필요)
+ Map variables = new HashMap<>();
+ variables.put("name", "홍길동");
+ variables.put("code", "123456");
+
+ // 카카오 옵션 설정
+ KakaoOption kakaoOption = new KakaoOption();
+ kakaoOption.setPfId(pfId);
+ kakaoOption.setTemplateId(templateId);
+ kakaoOption.setVariables(variables);
+
+ // 알림톡 메시지 생성
+ Message message = new Message();
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setKakaoOptions(kakaoOption);
+
+ try {
+ // 알림톡 발송
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("알림톡 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+
+ } catch (Exception e) {
+ System.err.println("알림톡 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java
new file mode 100644
index 0000000..b08c04a
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/KakaoBrandMessageExample.java
@@ -0,0 +1,198 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageType;
+import com.solapi.sdk.message.model.StorageType;
+import com.solapi.sdk.message.model.kakao.KakaoBmsOption;
+import com.solapi.sdk.message.model.kakao.KakaoOption;
+import com.solapi.sdk.message.model.kakao.bms.BmsButton;
+import com.solapi.sdk.message.model.kakao.bms.BmsButtonType;
+import com.solapi.sdk.message.model.kakao.bms.BmsChatBubbleType;
+import com.solapi.sdk.message.model.kakao.bms.BmsCoupon;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * 카카오 브랜드 메시지 (BMS_FREE) 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ *
+ * 브랜드 메시지 특징:
+ * - 다양한 템플릿 형태 지원 (TEXT, IMAGE, WIDE, COMMERCE 등)
+ * - 쿠폰, 버튼 등 다양한 구성요소 포함 가능
+ * - 캐러셀 형태의 메시지 지원
+ */
+public class KakaoBrandMessageExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+ String pfId = System.getenv("SOLAPI_KAKAO_PF_ID");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+ if (pfId == null) {
+ System.err.println("SOLAPI_KAKAO_PF_ID must be set for Kakao Brand Message");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 예제 1: TEXT 타입 브랜드 메시지
+ sendTextBrandMessage(messageService, sender, recipient, pfId);
+
+ // 예제 2: IMAGE 타입 브랜드 메시지 (이미지 파일 필요)
+ // sendImageBrandMessage(messageService, sender, recipient, pfId);
+ }
+
+ /**
+ * TEXT 타입 브랜드 메시지 발송
+ */
+ private static void sendTextBrandMessage(
+ DefaultMessageService messageService,
+ String sender,
+ String recipient,
+ String pfId
+ ) {
+ System.out.println("\n=== TEXT 타입 브랜드 메시지 발송 ===");
+
+ // BMS 버튼 생성
+ BmsButton webLinkButton = new BmsButton();
+ webLinkButton.setLinkType(BmsButtonType.WL);
+ webLinkButton.setName("바로가기");
+ webLinkButton.setLinkMobile("https://example.com");
+ webLinkButton.setLinkPc("https://example.com");
+
+ BmsButton channelAddButton = new BmsButton();
+ channelAddButton.setLinkType(BmsButtonType.AC);
+ channelAddButton.setName("채널 추가");
+
+ List buttons = Arrays.asList(webLinkButton, channelAddButton);
+
+ // 쿠폰 생성 (선택사항)
+ BmsCoupon coupon = new BmsCoupon();
+ coupon.setTitle("10% 할인쿠폰");
+ coupon.setDescription("첫 구매 고객 전용");
+
+ // BMS 옵션 설정
+ KakaoBmsOption bmsOption = new KakaoBmsOption();
+ bmsOption.setChatBubbleType(BmsChatBubbleType.TEXT);
+ bmsOption.setContent("브랜드 메시지 TEXT 타입 테스트입니다.");
+ bmsOption.setButtons(buttons);
+ bmsOption.setCoupon(coupon);
+ bmsOption.setAdult(false);
+
+ // 카카오 옵션 설정
+ KakaoOption kakaoOption = new KakaoOption();
+ kakaoOption.setPfId(pfId);
+ kakaoOption.setBms(bmsOption);
+
+ // 브랜드 메시지 생성
+ Message message = new Message();
+ message.setType(MessageType.BMS_FREE);
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setKakaoOptions(kakaoOption);
+
+ try {
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("TEXT 브랜드 메시지 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+
+ } catch (Exception e) {
+ System.err.println("TEXT 브랜드 메시지 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * IMAGE 타입 브랜드 메시지 발송 (이미지 파일 필요)
+ */
+ private static void sendImageBrandMessage(
+ DefaultMessageService messageService,
+ String sender,
+ String recipient,
+ String pfId
+ ) {
+ System.out.println("\n=== IMAGE 타입 브랜드 메시지 발송 ===");
+
+ try {
+ // 이미지 파일 로드
+ URL imageUrl = KakaoBrandMessageExample.class.getClassLoader().getResource("images/sample.jpg");
+ if (imageUrl == null) {
+ System.err.println("Sample image not found. Skipping IMAGE type example.");
+ return;
+ }
+
+ File tempFile = File.createTempFile("bms-image", ".jpg");
+ tempFile.deleteOnExit();
+ try (InputStream is = imageUrl.openStream()) {
+ Files.copy(is, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ // 이미지 업로드 (BMS 스토리지 타입)
+ String imageId = messageService.uploadFile(tempFile, StorageType.BMS, null);
+ System.out.println("이미지 업로드 완료 - imageId: " + imageId);
+
+ // BMS 버튼 생성
+ BmsButton webLinkButton = new BmsButton();
+ webLinkButton.setLinkType(BmsButtonType.WL);
+ webLinkButton.setName("자세히 보기");
+ webLinkButton.setLinkMobile("https://example.com");
+ webLinkButton.setLinkPc("https://example.com");
+
+ // BMS 옵션 설정
+ KakaoBmsOption bmsOption = new KakaoBmsOption();
+ bmsOption.setChatBubbleType(BmsChatBubbleType.IMAGE);
+ bmsOption.setImageId(imageId);
+ bmsOption.setImageLink("https://example.com/image");
+ bmsOption.setContent("IMAGE 타입 브랜드 메시지입니다.");
+ bmsOption.setButtons(Arrays.asList(webLinkButton));
+ bmsOption.setAdult(false);
+
+ KakaoOption kakaoOption = new KakaoOption();
+ kakaoOption.setPfId(pfId);
+ kakaoOption.setBms(bmsOption);
+
+ Message message = new Message();
+ message.setType(MessageType.BMS_FREE);
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setKakaoOptions(kakaoOption);
+
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("IMAGE 브랜드 메시지 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+
+ } catch (Exception e) {
+ System.err.println("IMAGE 브랜드 메시지 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java
new file mode 100644
index 0000000..a6eb8a2
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/MainExample.java
@@ -0,0 +1,78 @@
+package com.solapi.example;
+
+/**
+ * SOLAPI SDK 예제 메인 클래스
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ *
+ * 개별 예제 실행:
+ * ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms
+ * ./gradlew :solapi-kotlin-example-java:run -Pexample=GetBalance
+ */
+public class MainExample {
+
+ public static void main(String[] args) {
+ printLine(60);
+ System.out.println("SOLAPI SDK Java Examples");
+ printLine(60);
+ System.out.println();
+ System.out.println("Available examples:");
+ System.out.println();
+ System.out.println(" SMS/LMS/MMS:");
+ System.out.println(" SendSms - SMS 단건 발송");
+ System.out.println(" SendMms - MMS 이미지 첨부 발송");
+ System.out.println(" SendBatch - 대량 메시지 발송");
+ System.out.println(" SendScheduled - 예약 발송");
+ System.out.println(" SendVoice - 음성 메시지 발송");
+ System.out.println();
+ System.out.println(" Account:");
+ System.out.println(" GetBalance - 잔액 조회");
+ System.out.println(" GetMessageList - 발송 내역 조회");
+ System.out.println();
+ System.out.println(" Kakao:");
+ System.out.println(" KakaoAlimtalk - 알림톡 발송");
+ System.out.println(" KakaoBrandMessage - 브랜드 메시지 발송");
+ System.out.println();
+ System.out.println("Usage:");
+ System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample=");
+ System.out.println();
+ System.out.println("Example:");
+ System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample=SendSms");
+ System.out.println(" ./gradlew :solapi-kotlin-example-java:run -Pexample=GetBalance");
+ System.out.println();
+ System.out.println("Environment variables:");
+ System.out.println(" SOLAPI_API_KEY - " + maskValue(System.getenv("SOLAPI_API_KEY")));
+ System.out.println(" SOLAPI_API_SECRET - " + maskValue(System.getenv("SOLAPI_API_SECRET")));
+ System.out.println(" SOLAPI_SENDER - " + getEnvOrDefault("SOLAPI_SENDER", "(not set)"));
+ System.out.println(" SOLAPI_RECIPIENT - " + getEnvOrDefault("SOLAPI_RECIPIENT", "(not set)"));
+ System.out.println(" SOLAPI_KAKAO_PF_ID - " + getEnvOrDefault("SOLAPI_KAKAO_PF_ID", "(not set)"));
+ System.out.println();
+ }
+
+ private static void printLine(int count) {
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < count; i++) {
+ sb.append("=");
+ }
+ System.out.println(sb.toString());
+ }
+
+ private static String maskValue(String value) {
+ if (value == null || value.isEmpty()) {
+ return "(not set)";
+ }
+ if (value.length() <= 8) {
+ return "****";
+ }
+ return value.substring(0, 4) + "****" + value.substring(value.length() - 4);
+ }
+
+ private static String getEnvOrDefault(String key, String defaultValue) {
+ String value = System.getenv(key);
+ return (value == null || value.isEmpty()) ? defaultValue : value;
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java
new file mode 100644
index 0000000..516ea01
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendBatchExample.java
@@ -0,0 +1,77 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.request.SendRequestConfig;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 대량 메시지 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호 (테스트용으로 동일 번호 사용)
+ *
+ * 참고:
+ * - 한 번에 최대 10,000건까지 발송 가능
+ * - allowDuplicates 옵션으로 중복 수신번호 허용 가능
+ */
+public class SendBatchExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 여러 메시지 생성 (테스트를 위해 동일 수신자에게 발송)
+ List messages = new ArrayList<>();
+ for (int i = 1; i <= 3; i++) {
+ Message message = new Message();
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setText("대량 발송 테스트 메시지 " + i + "/3");
+ messages.add(message);
+ }
+
+ // 발송 설정 (중복 수신번호 허용)
+ SendRequestConfig config = new SendRequestConfig();
+ config.setAllowDuplicates(true);
+
+ try {
+ // 대량 메시지 발송
+ MultipleDetailMessageSentResponse response = messageService.send(messages, config);
+
+ System.out.println("대량 메시지 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+ System.out.println("Total Count: " + response.getGroupInfo().getCount());
+ if (response.getGroupInfo().getCount() != null) {
+ System.out.println(" - Total: " + response.getGroupInfo().getCount().getTotal());
+ System.out.println(" - Sent Total: " + response.getGroupInfo().getCount().getSentTotal());
+ }
+
+ } catch (Exception e) {
+ System.err.println("대량 메시지 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java
new file mode 100644
index 0000000..030b305
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendMmsExample.java
@@ -0,0 +1,92 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageType;
+import com.solapi.sdk.message.model.StorageType;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.io.File;
+import java.io.InputStream;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+
+/**
+ * MMS 이미지 첨부 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 참고: MMS 이미지 규격
+ * - 지원 포맷: JPG, JPEG
+ * - 최대 용량: 200KB
+ * - 권장 해상도: 1000x1000 이하
+ */
+public class SendMmsExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ try {
+ // 이미지 파일 로드 (리소스에서)
+ URL imageUrl = SendMmsExample.class.getClassLoader().getResource("images/sample.jpg");
+ if (imageUrl == null) {
+ System.err.println("Sample image not found in resources/images/sample.jpg");
+ System.err.println("Please add a JPG image file (max 200KB) to run this example.");
+ System.exit(1);
+ }
+
+ // 임시 파일로 복사 (URL에서 File로 변환)
+ File tempFile = File.createTempFile("mms-image", ".jpg");
+ tempFile.deleteOnExit();
+ try (InputStream is = imageUrl.openStream()) {
+ Files.copy(is, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ // 이미지 업로드
+ System.out.println("이미지 업로드 중...");
+ String imageId = messageService.uploadFile(tempFile, StorageType.MMS, null);
+ System.out.println("이미지 업로드 완료 - imageId: " + imageId);
+
+ // MMS 메시지 생성
+ Message message = new Message();
+ message.setType(MessageType.MMS);
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setText("안녕하세요. MMS 이미지 첨부 메시지입니다.");
+ message.setSubject("MMS 제목");
+ message.setImageId(imageId);
+
+ // 메시지 발송
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("MMS 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+
+ } catch (Exception e) {
+ System.err.println("MMS 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java
new file mode 100644
index 0000000..01de4b2
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendScheduledExample.java
@@ -0,0 +1,75 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.request.SendRequestConfig;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+
+/**
+ * 예약 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 참고:
+ * - 예약 시간은 현재 시간으로부터 최소 10분 이후여야 함
+ * - 최대 6개월 이내로 예약 가능
+ * - 과거 시간 지정 시 즉시 발송 처리됨
+ */
+public class SendScheduledExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 메시지 생성
+ Message message = new Message();
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setText("안녕하세요. 예약 발송 테스트 메시지입니다.");
+
+ // 10분 후 예약 발송 설정
+ LocalDateTime scheduledTime = LocalDateTime.now().plusMinutes(10);
+ ZoneId seoulZone = ZoneId.of("Asia/Seoul");
+
+ SendRequestConfig config = new SendRequestConfig();
+ config.setScheduledDateFromLocalDateTime(scheduledTime, seoulZone);
+
+ System.out.println("예약 시간: " + scheduledTime);
+
+ try {
+ // 예약 메시지 발송
+ MultipleDetailMessageSentResponse response = messageService.send(message, config);
+
+ System.out.println("예약 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+ System.out.println("Scheduled Date: " + response.getGroupInfo().getScheduledDate());
+
+ } catch (Exception e) {
+ System.err.println("예약 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java
new file mode 100644
index 0000000..1a3e124
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendSmsExample.java
@@ -0,0 +1,57 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+/**
+ * SMS 단건 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ */
+public class SendSmsExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 메시지 생성
+ Message message = new Message();
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setText("안녕하세요. SOLAPI SDK Java 예제입니다.");
+
+ try {
+ // 메시지 발송
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("SMS 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+ System.out.println("Message Count: " + response.getGroupInfo().getCount());
+
+ } catch (Exception e) {
+ System.err.println("SMS 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java
new file mode 100644
index 0000000..0ec94c1
--- /dev/null
+++ b/solapi-kotlin-example-java/src/main/java/com/solapi/example/SendVoiceExample.java
@@ -0,0 +1,70 @@
+package com.solapi.example;
+
+import com.solapi.sdk.SolapiClient;
+import com.solapi.sdk.message.dto.response.MultipleDetailMessageSentResponse;
+import com.solapi.sdk.message.model.Message;
+import com.solapi.sdk.message.model.MessageType;
+import com.solapi.sdk.message.model.voice.VoiceOption;
+import com.solapi.sdk.message.model.voice.VoiceType;
+import com.solapi.sdk.message.service.DefaultMessageService;
+
+/**
+ * 음성 메시지 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다.
+ * VoiceType: FEMALE(여성), MALE(남성)
+ */
+public class SendVoiceExample {
+
+ public static void main(String[] args) {
+ // 환경변수에서 설정 로드
+ String apiKey = System.getenv("SOLAPI_API_KEY");
+ String apiSecret = System.getenv("SOLAPI_API_SECRET");
+ String sender = System.getenv("SOLAPI_SENDER");
+ String recipient = System.getenv("SOLAPI_RECIPIENT");
+
+ if (apiKey == null || apiSecret == null) {
+ System.err.println("SOLAPI_API_KEY and SOLAPI_API_SECRET must be set");
+ System.exit(1);
+ }
+ if (sender == null || recipient == null) {
+ System.err.println("SOLAPI_SENDER and SOLAPI_RECIPIENT must be set");
+ System.exit(1);
+ }
+
+ // SDK 클라이언트 생성
+ DefaultMessageService messageService = SolapiClient.INSTANCE.createInstance(apiKey, apiSecret);
+
+ // 음성 옵션 설정
+ VoiceOption voiceOption = new VoiceOption();
+ voiceOption.setVoiceType(VoiceType.FEMALE); // 여성 음성
+ voiceOption.setHeaderMessage("안녕하세요."); // 헤더 메시지
+ voiceOption.setTailMessage("감사합니다."); // 테일 메시지
+
+ // 음성 메시지 생성
+ Message message = new Message();
+ message.setType(MessageType.VOICE);
+ message.setFrom(sender);
+ message.setTo(recipient);
+ message.setText("음성 메시지 본문입니다. 이 메시지는 TTS로 변환되어 발송됩니다.");
+ message.setVoiceOptions(voiceOption);
+
+ try {
+ // 음성 메시지 발송
+ MultipleDetailMessageSentResponse response = messageService.send(message, null);
+
+ System.out.println("음성 메시지 발송 성공!");
+ System.out.println("Group ID: " + response.getGroupInfo().getGroupId());
+
+ } catch (Exception e) {
+ System.err.println("음성 메시지 발송 실패: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/build.gradle.kts b/solapi-kotlin-example-kotlin/build.gradle.kts
new file mode 100644
index 0000000..9e51bae
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+ kotlin("jvm") version "2.3.0"
+ application
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation(rootProject)
+}
+
+kotlin {
+ jvmToolchain(8)
+}
+
+application {
+ val example = project.findProperty("example") as String? ?: "Main"
+ mainClass.set("com.solapi.example.${example}ExampleKt")
+}
+
+tasks.named("run") {
+ standardInput = System.`in`
+}
+
+// Disable distTar/distZip/installDist/startScripts from regular builds
+// These are only needed when explicitly building distributions
+tasks.named("distTar") { enabled = false }
+tasks.named("distZip") { enabled = false }
+tasks.named("installDist") { enabled = false }
+tasks.named("startScripts") { enabled = false }
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt
new file mode 100644
index 0000000..b993f7c
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetBalanceExample.kt
@@ -0,0 +1,41 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+
+/**
+ * 잔액 조회 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ try {
+ // 잔액 조회
+ val balance = messageService.getBalance()
+
+ println("=== 잔액 정보 ===")
+ println("Balance: ${balance.balance}")
+ println("Point: ${balance.point}")
+
+ // 일일 발송량 한도 조회
+ val quota = messageService.getQuota()
+
+ println()
+ println("=== 일일 발송량 한도 ===")
+ println("Quota: $quota")
+
+ } catch (e: Exception) {
+ System.err.println("조회 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt
new file mode 100644
index 0000000..eba8ca1
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/GetMessageListExample.kt
@@ -0,0 +1,91 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.dto.request.MessageListRequest
+import com.solapi.sdk.message.dto.response.MessageListResponse
+import com.solapi.sdk.message.model.MessageStatusType
+import java.time.LocalDateTime
+
+/**
+ * 발송 내역 조회 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ *
+ * 다양한 필터 옵션:
+ * - from: 발신번호
+ * - to: 수신번호
+ * - type: 메시지 타입 (SMS, LMS, MMS, ATA 등)
+ * - status: 메시지 상태 (PENDING, SENDING, COMPLETE, FAILED)
+ * - startDate/endDate: 날짜 범위
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ try {
+ // 1. 기본 조회 (최근 메시지)
+ println("=== 기본 메시지 목록 (최근 10건) ===")
+ val basicResponse = messageService.getMessageList(
+ MessageListRequest(limit = 10)
+ )
+ printMessageList(basicResponse)
+
+ // 2. 발송 완료 메시지 조회
+ println("\n=== 발송 완료 메시지 ===")
+ val completedResponse = messageService.getMessageList(
+ MessageListRequest(
+ status = MessageStatusType.COMPLETE,
+ limit = 5
+ )
+ )
+ printMessageList(completedResponse)
+
+ // 3. 날짜 범위 조회 (최근 7일)
+ println("\n=== 최근 7일간 메시지 ===")
+ val dateRequest = MessageListRequest(limit = 5).apply {
+ setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7))
+ setEndDateFromLocalDateTime(LocalDateTime.now())
+ }
+ val dateResponse = messageService.getMessageList(dateRequest)
+ printMessageList(dateResponse)
+
+ // 4. SMS 타입만 조회
+ println("\n=== SMS 타입 메시지 ===")
+ val smsResponse = messageService.getMessageList(
+ MessageListRequest(type = "SMS", limit = 5)
+ )
+ printMessageList(smsResponse)
+
+ } catch (e: Exception) {
+ System.err.println("메시지 목록 조회 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
+
+private fun printMessageList(response: MessageListResponse?) {
+ val messageList = response?.messageList
+ if (messageList.isNullOrEmpty()) {
+ println(" (조회 결과 없음)")
+ return
+ }
+
+ println(" 조회 건수: ${messageList.size}")
+
+ messageList.entries.take(3).forEach { (id, msg) ->
+ println(" - ID: $id")
+ println(" Type: ${msg.type}, To: ${msg.to}")
+ println(" Status: ${msg.statusCode}, Text: ${msg.text?.take(30)}${if ((msg.text?.length ?: 0) > 30) "..." else ""}")
+ }
+
+ if (messageList.size > 3) {
+ println(" ... (외 ${messageList.size - 3}건)")
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt
new file mode 100644
index 0000000..6d32281
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoAlimtalkExample.kt
@@ -0,0 +1,72 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.kakao.KakaoOption
+
+/**
+ * 카카오 알림톡 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID
+ *
+ * 알림톡 특징:
+ * - 사전에 검수 승인된 템플릿만 사용 가능
+ * - 정보성 메시지 전용 (광고 불가)
+ * - 변수 치환을 통해 동적 내용 전달 가능
+ * 번외:
+ * 브랜드 메시지 템플릿과 동일한 형태의 코드로 발송하실 수 있습니다!
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+ val pfId = System.getenv("SOLAPI_KAKAO_PF_ID")
+ ?: error("SOLAPI_KAKAO_PF_ID must be set for Kakao Alimtalk")
+ val templateId = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID")
+ ?: error("SOLAPI_KAKAO_TEMPLATE_ID must be set for Kakao Alimtalk")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 템플릿 변수 설정 (템플릿에 맞게 조정 필요)
+ val variables = mapOf(
+ "name" to "홍길동",
+ "code" to "123456"
+ )
+
+ // 알림톡 메시지 생성
+ val message = Message(
+ from = sender,
+ to = recipient,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ templateId = templateId,
+ variables = variables
+ )
+ )
+
+ try {
+ // 알림톡 발송
+ val response = messageService.send(message)
+
+ println("알림톡 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+
+ } catch (e: Exception) {
+ System.err.println("알림톡 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt
new file mode 100644
index 0000000..e6e0d95
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/KakaoBrandMessageExample.kt
@@ -0,0 +1,188 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.StorageType
+import com.solapi.sdk.message.model.kakao.KakaoBmsOption
+import com.solapi.sdk.message.model.kakao.KakaoOption
+import com.solapi.sdk.message.model.kakao.bms.BmsButton
+import com.solapi.sdk.message.model.kakao.bms.BmsButtonType
+import com.solapi.sdk.message.model.kakao.bms.BmsChatBubbleType
+import com.solapi.sdk.message.model.kakao.bms.BmsCoupon
+import com.solapi.sdk.message.service.DefaultMessageService
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * 카카오 브랜드 메시지 (BMS_FREE) 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ *
+ * 브랜드 메시지 특징:
+ * - 다양한 템플릿 형태 지원 (TEXT, IMAGE, WIDE, COMMERCE 등)
+ * - 쿠폰, 버튼 등 다양한 구성요소 포함 가능
+ * - 캐러셀 형태의 메시지 지원
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+ val pfId = System.getenv("SOLAPI_KAKAO_PF_ID")
+ ?: error("SOLAPI_KAKAO_PF_ID must be set for Kakao Brand Message")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 예제 1: TEXT 타입 브랜드 메시지
+ sendTextBrandMessage(messageService, sender, recipient, pfId)
+
+ // 예제 2: IMAGE 타입 브랜드 메시지 (이미지 파일 필요)
+ // sendImageBrandMessage(messageService, sender, recipient, pfId)
+}
+
+/**
+ * TEXT 타입 브랜드 메시지 발송
+ */
+private fun sendTextBrandMessage(
+ messageService: DefaultMessageService,
+ sender: String,
+ recipient: String,
+ pfId: String
+) {
+ println("\n=== TEXT 타입 브랜드 메시지 발송 ===")
+
+ // BMS 버튼 생성
+ val buttons = listOf(
+ BmsButton(
+ linkType = BmsButtonType.WL,
+ name = "바로가기",
+ linkMobile = "https://example.com",
+ linkPc = "https://example.com"
+ ),
+ BmsButton(
+ linkType = BmsButtonType.AC, // Add Channel
+ name = "채널 추가"
+ )
+ )
+
+ // 쿠폰 생성 (선택사항)
+ val coupon = BmsCoupon(
+ title = "10% 할인쿠폰",
+ description = "첫 구매 고객 전용"
+ )
+
+ // BMS 옵션 설정
+ val bmsOption = KakaoBmsOption(
+ chatBubbleType = BmsChatBubbleType.TEXT,
+ content = "브랜드 메시지 TEXT 타입 테스트입니다.",
+ buttons = buttons,
+ coupon = coupon,
+ adult = false
+ )
+
+ // 브랜드 메시지 생성
+ val message = Message(
+ type = MessageType.BMS_FREE,
+ from = sender,
+ to = recipient,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+ )
+
+ try {
+ val response = messageService.send(message)
+
+ println("TEXT 브랜드 메시지 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+
+ } catch (e: Exception) {
+ System.err.println("TEXT 브랜드 메시지 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
+
+/**
+ * IMAGE 타입 브랜드 메시지 발송 (이미지 파일 필요)
+ */
+private fun sendImageBrandMessage(
+ messageService: DefaultMessageService,
+ sender: String,
+ recipient: String,
+ pfId: String
+) {
+ println("\n=== IMAGE 타입 브랜드 메시지 발송 ===")
+
+ try {
+ // 이미지 파일 로드
+ val imageUrl = object {}.javaClass.classLoader.getResource("images/sample.jpg")
+ if (imageUrl == null) {
+ println("Sample image not found. Skipping IMAGE type example.")
+ return
+ }
+
+ val tempFile = File.createTempFile("bms-image", ".jpg").apply {
+ deleteOnExit()
+ }
+ imageUrl.openStream().use { input ->
+ Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ // 이미지 업로드 (BMS 스토리지 타입)
+ val imageId = messageService.uploadFile(tempFile, StorageType.BMS)
+ println("이미지 업로드 완료 - imageId: $imageId")
+
+ // BMS 버튼 생성
+ val buttons = listOf(
+ BmsButton(
+ linkType = BmsButtonType.WL,
+ name = "자세히 보기",
+ linkMobile = "https://example.com",
+ linkPc = "https://example.com"
+ )
+ )
+
+ // BMS 옵션 설정
+ val bmsOption = KakaoBmsOption(
+ chatBubbleType = BmsChatBubbleType.IMAGE,
+ imageId = imageId,
+ imageLink = "https://example.com/image",
+ content = "IMAGE 타입 브랜드 메시지입니다.",
+ buttons = buttons,
+ adult = false
+ )
+
+ val message = Message(
+ type = MessageType.BMS_FREE,
+ from = sender,
+ to = recipient,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+ )
+
+ val response = messageService.send(message)
+
+ println("IMAGE 브랜드 메시지 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+
+ } catch (e: Exception) {
+ System.err.println("IMAGE 브랜드 메시지 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt
new file mode 100644
index 0000000..1cf922b
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/MainExample.kt
@@ -0,0 +1,58 @@
+package com.solapi.example
+
+/**
+ * SOLAPI SDK 예제 메인 클래스
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ *
+ * 개별 예제 실행:
+ * ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms
+ * ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=GetBalance
+ */
+fun main() {
+ println("=".repeat(60))
+ println("SOLAPI SDK Kotlin Examples")
+ println("=".repeat(60))
+ println()
+ println("Available examples:")
+ println()
+ println(" SMS/LMS/MMS:")
+ println(" SendSms - SMS 단건 발송")
+ println(" SendMms - MMS 이미지 첨부 발송")
+ println(" SendBatch - 대량 메시지 발송")
+ println(" SendScheduled - 예약 발송")
+ println(" SendVoice - 음성 메시지 발송")
+ println()
+ println(" Account:")
+ println(" GetBalance - 잔액 조회")
+ println(" GetMessageList - 발송 내역 조회")
+ println()
+ println(" Kakao:")
+ println(" KakaoAlimtalk - 알림톡 발송")
+ println(" KakaoBrandMessage - 브랜드 메시지 발송")
+ println()
+ println("Usage:")
+ println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=")
+ println()
+ println("Example:")
+ println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=SendSms")
+ println(" ./gradlew :solapi-kotlin-example-kotlin:run -Pexample=GetBalance")
+ println()
+ println("Environment variables:")
+ println(" SOLAPI_API_KEY - ${maskValue(System.getenv("SOLAPI_API_KEY"))}")
+ println(" SOLAPI_API_SECRET - ${maskValue(System.getenv("SOLAPI_API_SECRET"))}")
+ println(" SOLAPI_SENDER - ${System.getenv("SOLAPI_SENDER") ?: "(not set)"}")
+ println(" SOLAPI_RECIPIENT - ${System.getenv("SOLAPI_RECIPIENT") ?: "(not set)"}")
+ println(" SOLAPI_KAKAO_PF_ID - ${System.getenv("SOLAPI_KAKAO_PF_ID") ?: "(not set)"}")
+ println()
+}
+
+private fun maskValue(value: String?): String {
+ if (value.isNullOrEmpty()) return "(not set)"
+ if (value.length <= 8) return "****"
+ return "${value.take(4)}****${value.takeLast(4)}"
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt
new file mode 100644
index 0000000..331401d
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendBatchExample.kt
@@ -0,0 +1,62 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.dto.request.SendRequestConfig
+import com.solapi.sdk.message.model.Message
+
+/**
+ * 대량 메시지 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호 (테스트용으로 동일 번호 사용)
+ *
+ * 참고:
+ * - 한 번에 최대 10,000건까지 발송 가능
+ * - allowDuplicates 옵션으로 중복 수신번호 허용 가능
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 여러 메시지 생성 (테스트를 위해 동일 수신자에게 발송)
+ val messages = (1..3).map { i ->
+ Message(
+ from = sender,
+ to = recipient,
+ text = "대량 발송 테스트 메시지 $i/3"
+ )
+ }
+
+ // 발송 설정 (중복 수신번호 허용)
+ val config = SendRequestConfig(allowDuplicates = true)
+
+ try {
+ // 대량 메시지 발송
+ val response = messageService.send(messages, config)
+
+ println("대량 메시지 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+ println("Total Count: ${response.groupInfo?.count}")
+ response.groupInfo?.count?.let { count ->
+ println(" - Total: ${count.total}")
+ println(" - Sent Total: ${count.sentTotal}")
+ }
+
+ } catch (e: Exception) {
+ System.err.println("대량 메시지 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt
new file mode 100644
index 0000000..db09300
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendMmsExample.kt
@@ -0,0 +1,77 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.StorageType
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+/**
+ * MMS 이미지 첨부 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 참고: MMS 이미지 규격
+ * - 지원 포맷: JPG, JPEG
+ * - 최대 용량: 200KB
+ * - 권장 해상도: 1000x1000 이하
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ try {
+ // 이미지 파일 로드 (리소스에서)
+ val imageUrl = object {}.javaClass.classLoader.getResource("images/sample.jpg")
+ ?: error("Sample image not found in resources/images/sample.jpg. Please add a JPG image file (max 200KB).")
+
+ // 임시 파일로 복사
+ val tempFile = File.createTempFile("mms-image", ".jpg").apply {
+ deleteOnExit()
+ }
+ imageUrl.openStream().use { input ->
+ Files.copy(input, tempFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
+ }
+
+ // 이미지 업로드
+ println("이미지 업로드 중...")
+ val imageId = messageService.uploadFile(tempFile, StorageType.MMS)
+ println("이미지 업로드 완료 - imageId: $imageId")
+
+ // MMS 메시지 생성
+ val message = Message(
+ type = MessageType.MMS,
+ from = sender,
+ to = recipient,
+ text = "안녕하세요. MMS 이미지 첨부 메시지입니다.",
+ subject = "MMS 제목",
+ imageId = imageId
+ )
+
+ // 메시지 발송
+ val response = messageService.send(message)
+
+ println("MMS 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+
+ } catch (e: Exception) {
+ System.err.println("MMS 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt
new file mode 100644
index 0000000..9b5a9fd
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendScheduledExample.kt
@@ -0,0 +1,66 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.dto.request.SendRequestConfig
+import com.solapi.sdk.message.model.Message
+import java.time.LocalDateTime
+import java.time.ZoneId
+
+/**
+ * 예약 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 참고:
+ * - 예약 시간은 현재 시간으로부터 최소 10분 이후여야 함
+ * - 최대 6개월 이내로 예약 가능
+ * - 과거 시간 지정 시 즉시 발송 처리됨
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 메시지 생성
+ val message = Message(
+ from = sender,
+ to = recipient,
+ text = "안녕하세요. 예약 발송 테스트 메시지입니다."
+ )
+
+ // 10분 후 예약 발송 설정
+ val scheduledTime = LocalDateTime.now().plusMinutes(10)
+ val seoulZone = ZoneId.of("Asia/Seoul")
+
+ val config = SendRequestConfig().apply {
+ setScheduledDateFromLocalDateTime(scheduledTime, seoulZone)
+ }
+
+ println("예약 시간: $scheduledTime")
+
+ try {
+ // 예약 메시지 발송
+ val response = messageService.send(message, config)
+
+ println("예약 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+ println("Scheduled Date: ${response.groupInfo?.scheduledDate}")
+
+ } catch (e: Exception) {
+ System.err.println("예약 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt
new file mode 100644
index 0000000..aa99368
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendSmsExample.kt
@@ -0,0 +1,48 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+
+/**
+ * SMS 단건 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 메시지 생성 (Kotlin data class)
+ val message = Message(
+ from = sender,
+ to = recipient,
+ text = "안녕하세요. SOLAPI SDK Kotlin 예제입니다."
+ )
+
+ try {
+ // 메시지 발송
+ val response = messageService.send(message)
+
+ println("SMS 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+ println("Message Count: ${response.groupInfo?.count}")
+
+ } catch (e: Exception) {
+ System.err.println("SMS 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt
new file mode 100644
index 0000000..bec826f
--- /dev/null
+++ b/solapi-kotlin-example-kotlin/src/main/kotlin/com/solapi/example/SendVoiceExample.kt
@@ -0,0 +1,62 @@
+package com.solapi.example
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.voice.VoiceOption
+import com.solapi.sdk.message.model.voice.VoiceType
+
+/**
+ * 음성 메시지 발송 예제
+ *
+ * 환경변수 설정:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 수신번호
+ *
+ * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다.
+ * VoiceType: FEMALE(여성), MALE(남성)
+ */
+fun main() {
+ // 환경변수에서 설정 로드
+ val apiKey = System.getenv("SOLAPI_API_KEY")
+ ?: error("SOLAPI_API_KEY must be set")
+ val apiSecret = System.getenv("SOLAPI_API_SECRET")
+ ?: error("SOLAPI_API_SECRET must be set")
+ val sender = System.getenv("SOLAPI_SENDER")
+ ?: error("SOLAPI_SENDER must be set")
+ val recipient = System.getenv("SOLAPI_RECIPIENT")
+ ?: error("SOLAPI_RECIPIENT must be set")
+
+ // SDK 클라이언트 생성
+ val messageService = SolapiClient.createInstance(apiKey, apiSecret)
+
+ // 음성 옵션 설정
+ val voiceOption = VoiceOption(
+ voiceType = VoiceType.FEMALE, // 여성 음성
+ headerMessage = "안녕하세요.", // 헤더 메시지
+ tailMessage = "감사합니다." // 테일 메시지
+ )
+
+ // 음성 메시지 생성
+ val message = Message(
+ type = MessageType.VOICE,
+ from = sender,
+ to = recipient,
+ text = "음성 메시지 본문입니다. 이 메시지는 TTS로 변환되어 발송됩니다.",
+ voiceOptions = voiceOption
+ )
+
+ try {
+ // 음성 메시지 발송
+ val response = messageService.send(message)
+
+ println("음성 메시지 발송 성공!")
+ println("Group ID: ${response.groupInfo?.groupId}")
+
+ } catch (e: Exception) {
+ System.err.println("음성 메시지 발송 실패: ${e.message}")
+ e.printStackTrace()
+ }
+}
diff --git a/src/main/java/com/solapi/sdk/NurigoApp.kt b/src/main/java/com/solapi/sdk/NurigoApp.kt
deleted file mode 100644
index 2949efb..0000000
--- a/src/main/java/com/solapi/sdk/NurigoApp.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package com.solapi.sdk
-
-import com.solapi.sdk.message.service.DefaultMessageService
-
-object NurigoApp {
- @Deprecated(
- message = "This method will be removed in a future version",
- replaceWith = ReplaceWith("SolapiClient.createInstance(apiKey, apiSecretKey)"),
- level = DeprecationLevel.WARNING
- )
- fun initialize(apiKey: String, apiSecretKey: String, apiUrl: String): DefaultMessageService {
- return DefaultMessageService(apiKey, apiSecretKey, apiUrl)
- }
-}
\ No newline at end of file
diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt
index a0d56fd..519da29 100644
--- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt
+++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListBaseRequest.kt
@@ -1,9 +1,12 @@
package com.solapi.sdk.message.dto.request
-import kotlinx.serialization.Contextual
+import com.solapi.sdk.message.lib.toKotlinInstant
+import com.solapi.sdk.message.model.CommonMessageProperty
import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
+import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
-import com.solapi.sdk.message.model.CommonMessageProperty
@Serializable
data class MessageListBaseRequest(
@@ -20,8 +23,24 @@ data class MessageListBaseRequest(
var value: String? = null,
@Contextual
- var startDate: LocalDateTime? = null,
+ var startDate: Instant? = null,
@Contextual
- var endDate: LocalDateTime? = null
-) : CommonMessageProperty
+ var endDate: Instant? = null
+) : CommonMessageProperty {
+ @JvmOverloads
+ fun setStartDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.startDate = localDateTime.toKotlinInstant(zoneId)
+ }
+
+ @JvmOverloads
+ fun setEndDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.endDate = localDateTime.toKotlinInstant(zoneId)
+ }
+}
diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt
index 4f973fa..8e81242 100644
--- a/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt
+++ b/src/main/java/com/solapi/sdk/message/dto/request/MessageListRequest.kt
@@ -1,10 +1,13 @@
package com.solapi.sdk.message.dto.request
-import kotlinx.serialization.Contextual
-import java.time.LocalDateTime
-import kotlinx.serialization.Serializable
+import com.solapi.sdk.message.lib.toKotlinInstant
import com.solapi.sdk.message.model.CommonMessageProperty
import com.solapi.sdk.message.model.MessageStatusType
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
@Serializable
data class MessageListRequest(
@@ -58,16 +61,32 @@ data class MessageListRequest(
* 조회 할 시작 날짜
*/
@Contextual
- var startDate: LocalDateTime? = null,
+ var startDate: Instant? = null,
/**
* 조회 할 종료 날짜
*/
@Contextual
- var endDate: LocalDateTime? = null,
+ var endDate: Instant? = null,
/**
* 발송 상태
*/
var status: MessageStatusType? = null
-) : CommonMessageProperty
+) : CommonMessageProperty {
+ @JvmOverloads
+ fun setStartDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.startDate = localDateTime.toKotlinInstant(zoneId)
+ }
+
+ @JvmOverloads
+ fun setEndDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.endDate = localDateTime.toKotlinInstant(zoneId)
+ }
+}
diff --git a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt
index c10cff3..7a72264 100644
--- a/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt
+++ b/src/main/java/com/solapi/sdk/message/dto/request/MultipleDetailMessageSendingRequest.kt
@@ -1,9 +1,12 @@
package com.solapi.sdk.message.dto.request
+import com.solapi.sdk.message.lib.toKotlinInstant
+import com.solapi.sdk.message.model.Message
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
import kotlinx.serialization.Contextual
-import java.time.Instant
import kotlinx.serialization.Serializable
-import com.solapi.sdk.message.model.Message
@Serializable
data class MultipleDetailMessageSendingRequest(
@@ -11,4 +14,12 @@ data class MultipleDetailMessageSendingRequest(
@Contextual
var scheduledDate: Instant? = null,
var showMessageList: Boolean = false,
-) : AbstractDefaultMessageRequest()
+) : AbstractDefaultMessageRequest() {
+ @JvmOverloads
+ fun setScheduledDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.scheduledDate = localDateTime.toKotlinInstant(zoneId)
+ }
+}
diff --git a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt
index 7e60011..2160019 100644
--- a/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt
+++ b/src/main/java/com/solapi/sdk/message/dto/request/SendRequestConfig.kt
@@ -1,8 +1,11 @@
package com.solapi.sdk.message.dto.request
+import com.solapi.sdk.message.lib.toKotlinInstant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
-import java.time.Instant
@Serializable
data class SendRequestConfig(
@@ -11,4 +14,12 @@ data class SendRequestConfig(
var showMessageList: Boolean = false,
@Contextual
var scheduledDate: Instant? = null
-)
+) {
+ @JvmOverloads
+ fun setScheduledDateFromLocalDateTime(
+ localDateTime: LocalDateTime,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) {
+ this.scheduledDate = localDateTime.toKotlinInstant(zoneId)
+ }
+}
diff --git a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt
index 61aa8e3..e221749 100644
--- a/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt
+++ b/src/main/java/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQuery.kt
@@ -1,11 +1,24 @@
package com.solapi.sdk.message.dto.request.kakao
-import java.time.Instant
+import com.solapi.sdk.message.lib.toKotlinInstant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.time.Instant
data class KakaoTemplateDateQuery(
val date: Instant,
val queryCondition: KakaoAlimtalkTemplateDateQueryCondition,
) {
+ @JvmOverloads
+ constructor(
+ localDateTime: LocalDateTime,
+ queryCondition: KakaoAlimtalkTemplateDateQueryCondition,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ) : this(
+ date = localDateTime.toKotlinInstant(zoneId),
+ queryCondition = queryCondition
+ )
+
enum class KakaoAlimtalkTemplateDateQueryCondition {
EQUALS,
GREATER_THEN_OR_EQUAL,
@@ -13,4 +26,19 @@ data class KakaoTemplateDateQuery(
LESS_THEN_OR_EQUAL,
LESS_THEN
}
+
+ companion object {
+ @JvmStatic
+ @JvmOverloads
+ fun fromLocalDateTime(
+ localDateTime: LocalDateTime,
+ queryCondition: KakaoAlimtalkTemplateDateQueryCondition,
+ zoneId: ZoneId = ZoneId.systemDefault()
+ ): KakaoTemplateDateQuery {
+ return KakaoTemplateDateQuery(
+ date = localDateTime.toKotlinInstant(zoneId),
+ queryCondition = queryCondition
+ )
+ }
+ }
}
diff --git a/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt b/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt
index 1172441..a5f84d4 100644
--- a/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt
+++ b/src/main/java/com/solapi/sdk/message/lib/Authenticator.kt
@@ -1,10 +1,9 @@
package com.solapi.sdk.message.lib
import com.solapi.sdk.message.exception.SolapiApiKeyException
+import kotlin.time.Clock
import org.apache.commons.codec.binary.Hex
import java.nio.charset.StandardCharsets
-import java.time.ZoneId
-import java.time.ZonedDateTime
import java.util.*
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@@ -25,7 +24,7 @@ internal class Authenticator(
}
val salt = UUID.randomUUID().toString().replace(Regex("-"), "")
- val date = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toString().split(Regex("\\[")).toTypedArray()[0]
+ val date = Clock.System.now().toString()
val encryptionInstance = Mac.getInstance(ENCRYPTION_ALGORITHM)
val secretKey = SecretKeySpec(apiSecretKey.toByteArray(StandardCharsets.UTF_8), ENCRYPTION_ALGORITHM)
diff --git a/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt b/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt
index 1ef56b6..0ca85a2 100644
--- a/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt
+++ b/src/main/java/com/solapi/sdk/message/lib/JsonSupport.kt
@@ -1,11 +1,6 @@
package com.solapi.sdk.message.lib
-import java.time.Instant
-import java.time.LocalDateTime
-import java.time.OffsetDateTime
-import java.time.ZoneOffset
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
+import kotlin.time.Instant
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
@@ -17,53 +12,24 @@ import kotlinx.serialization.modules.SerializersModule
/**
* 중앙 집중식 Json 및 직렬화 설정.
- * LocalDateTime 은 ISO_LOCAL_DATE_TIME 문자열로 직렬화/역직렬화합니다.
- * Instant 는 ISO_INSTANT(Z) 문자열로 직렬화/역직렬화합니다.
+ * Instant 는 ISO-8601(Z) 문자열로 직렬화/역직렬화합니다.
*/
object JsonSupport {
private object InstantIsoSerializer : KSerializer {
- private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT
-
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: Instant) {
- encoder.encodeString(formatter.format(value))
+ encoder.encodeString(value.toString())
}
override fun deserialize(decoder: Decoder): Instant {
val text = decoder.decodeString()
- // Lenient parsing: accept ISO_INSTANT, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME, ISO_LOCAL_DATE_TIME(UTC)
- return runCatching { Instant.parse(text) }
- .recoverCatching { OffsetDateTime.parse(text, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toInstant() }
- .recoverCatching { ZonedDateTime.parse(text, DateTimeFormatter.ISO_ZONED_DATE_TIME).toInstant() }
- .recoverCatching { LocalDateTime.parse(text, DateTimeFormatter.ISO_LOCAL_DATE_TIME).toInstant(ZoneOffset.UTC) }
- .getOrElse { throw it }
- }
- }
- private object LocalDateTimeIsoSerializer : KSerializer {
- private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
-
- override val descriptor: SerialDescriptor =
- PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)
-
- override fun serialize(encoder: Encoder, value: LocalDateTime) {
- encoder.encodeString(value.format(formatter))
- }
-
- override fun deserialize(decoder: Decoder): LocalDateTime {
- val text = decoder.decodeString()
- // Lenient parsing: accept ISO_LOCAL_DATE_TIME, ISO_OFFSET_DATE_TIME, ISO_ZONED_DATE_TIME, ISO_INSTANT
- return runCatching { LocalDateTime.parse(text, formatter) }
- .recoverCatching { OffsetDateTime.parse(text, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime() }
- .recoverCatching { ZonedDateTime.parse(text, DateTimeFormatter.ISO_ZONED_DATE_TIME).toLocalDateTime() }
- .recoverCatching { LocalDateTime.ofInstant(Instant.parse(text), ZoneOffset.UTC) }
- .getOrElse { throw it }
+ return Instant.parse(text)
}
}
val serializersModule: SerializersModule = SerializersModule {
- contextual(LocalDateTime::class, LocalDateTimeIsoSerializer)
contextual(Instant::class, InstantIsoSerializer)
}
diff --git a/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt
new file mode 100644
index 0000000..abe42f7
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/lib/LocalDateTimeSupport.kt
@@ -0,0 +1,38 @@
+package com.solapi.sdk.message.lib
+
+import kotlin.time.Instant
+import kotlin.time.toKotlinInstant
+import java.time.LocalDateTime
+import java.time.ZoneId
+
+/**
+ * LocalDateTime을 kotlin.time.Instant로 변환하는 유틸리티.
+ *
+ * LocalDateTime은 시간대 정보가 없으므로, 변환 시 시간대를 지정해야 합니다.
+ * 기본값은 시스템 기본 시간대(ZoneId.systemDefault())입니다.
+ */
+object LocalDateTimeSupport {
+ /**
+ * LocalDateTime을 kotlin.time.Instant로 변환합니다.
+ *
+ * @param localDateTime 변환할 LocalDateTime
+ * @param zoneId 적용할 시간대 (기본값: 시스템 기본 시간대)
+ * @return 변환된 kotlin.time.Instant
+ */
+ @JvmStatic
+ @JvmOverloads
+ fun toKotlinInstant(localDateTime: LocalDateTime, zoneId: ZoneId = ZoneId.systemDefault()): Instant {
+ return localDateTime.atZone(zoneId).toInstant().toKotlinInstant()
+ }
+}
+
+/**
+ * LocalDateTime을 kotlin.time.Instant로 변환하는 확장 함수.
+ *
+ * @param zoneId 적용할 시간대 (기본값: 시스템 기본 시간대)
+ * @return 변환된 kotlin.time.Instant
+ */
+@JvmOverloads
+fun LocalDateTime.toKotlinInstant(zoneId: ZoneId = ZoneId.systemDefault()): Instant {
+ return LocalDateTimeSupport.toKotlinInstant(this, zoneId)
+}
diff --git a/src/main/java/com/solapi/sdk/message/model/Message.kt b/src/main/java/com/solapi/sdk/message/model/Message.kt
index 3b764ed..1ea423d 100644
--- a/src/main/java/com/solapi/sdk/message/model/Message.kt
+++ b/src/main/java/com/solapi/sdk/message/model/Message.kt
@@ -10,7 +10,7 @@ import com.solapi.sdk.message.model.voice.VoiceOption
@Serializable
data class Message (
/**
- * 카카오 알림톡, 친구톡 발송을 위한 파라미터
+ * 카카오 알림톡 발송을 위한 파라미터
*/
var kakaoOptions: KakaoOption? = null,
diff --git a/src/main/java/com/solapi/sdk/message/model/MessageType.kt b/src/main/java/com/solapi/sdk/message/model/MessageType.kt
index 78e5a14..4be880e 100644
--- a/src/main/java/com/solapi/sdk/message/model/MessageType.kt
+++ b/src/main/java/com/solapi/sdk/message/model/MessageType.kt
@@ -23,12 +23,16 @@ enum class MessageType {
/**
* 카카오 친구톡
+ * @deprecated 2025/12/31 부로 지원 종료됨
*/
+ @Deprecated("2025/12/31 부로 지원 종료됨", level = DeprecationLevel.WARNING)
CTA,
/**
* 이미지가 포함된 카카오 친구톡(이미지 1장 업로드 가능)
+ * @deprecated 2025/12/31 부로 지원 종료됨
*/
+ @Deprecated("2025/12/31 부로 지원 종료됨", level = DeprecationLevel.WARNING)
CTI,
/**
@@ -84,5 +88,10 @@ enum class MessageType {
/**
* 카카오 브랜드 메시지 와이드 리스트 타입
*/
- BMS_WIDE_ITEM_LIST;
+ BMS_WIDE_ITEM_LIST,
+
+ /**
+ * 카카오 브랜드 메시지 자유형 타입
+ */
+ BMS_FREE;
}
\ No newline at end of file
diff --git a/src/main/java/com/solapi/sdk/message/model/StorageType.kt b/src/main/java/com/solapi/sdk/message/model/StorageType.kt
index 15224bd..23c2187 100644
--- a/src/main/java/com/solapi/sdk/message/model/StorageType.kt
+++ b/src/main/java/com/solapi/sdk/message/model/StorageType.kt
@@ -5,5 +5,11 @@ enum class StorageType {
MMS,
DOCUMENT,
RCS,
- FAX
+ FAX,
+ BMS,
+ BMS_WIDE,
+ BMS_WIDE_MAIN_ITEM_LIST,
+ BMS_WIDE_SUB_ITEM_LIST,
+ BMS_CAROUSEL_FEED_LIST,
+ BMS_CAROUSEL_COMMERCE_LIST
}
\ No newline at end of file
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt
index 719f944..98ab7d9 100644
--- a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoBmsOption.kt
@@ -1,9 +1,24 @@
package com.solapi.sdk.message.model.kakao
+import com.solapi.sdk.message.model.kakao.bms.*
import kotlinx.serialization.Serializable
@Serializable
data class KakaoBmsOption(
- var targeting: KakaoBmsTargeting? = null
+ var targeting: KakaoBmsTargeting? = null,
+ var chatBubbleType: BmsChatBubbleType? = null,
+ var adult: Boolean? = null,
+ var header: String? = null,
+ var imageId: String? = null,
+ var imageLink: String? = null,
+ var additionalContent: String? = null,
+ var content: String? = null,
+ var carousel: BmsCarousel? = null,
+ var mainWideItem: BmsMainWideItem? = null,
+ var subWideItemList: List? = null,
+ var buttons: List? = null,
+ var coupon: BmsCoupon? = null,
+ var commerce: BmsCommerce? = null,
+ var video: BmsVideo? = null
)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt
index ce83127..991bef2 100644
--- a/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/KakaoOption.kt
@@ -39,7 +39,7 @@ data class KakaoOption(
var adFlag: Boolean = false,
/**
- * 친구톡 버튼
+ * 카카오 버튼
*/
var buttons: List? = null,
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt
new file mode 100644
index 0000000..44ffe6b
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButton.kt
@@ -0,0 +1,15 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsButton(
+ var linkType: BmsButtonType? = null,
+ var name: String? = null,
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null,
+ var chatExtra: String? = null,
+ var targetOut: Boolean? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt
new file mode 100644
index 0000000..5a19bd4
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsButtonType.kt
@@ -0,0 +1,49 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+/**
+ * BMS Free 버튼 타입
+ */
+@Serializable
+enum class BmsButtonType {
+ /**
+ * 웹링크
+ */
+ WL,
+
+ /**
+ * 앱링크
+ */
+ AL,
+
+ /**
+ * 채널 추가
+ */
+ AC,
+
+ /**
+ * 봇 키워드
+ */
+ BK,
+
+ /**
+ * 메시지 전달
+ */
+ MD,
+
+ /**
+ * 상담 요청
+ */
+ BC,
+
+ /**
+ * 봇 전환
+ */
+ BT,
+
+ /**
+ * 비즈니스폼
+ */
+ BF
+}
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt
new file mode 100644
index 0000000..137c332
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarousel.kt
@@ -0,0 +1,10 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsCarousel(
+ var head: BmsCarouselHead? = null,
+ var list: List? = null,
+ var tail: BmsCarouselTail? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt
new file mode 100644
index 0000000..dadd050
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselHead.kt
@@ -0,0 +1,14 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsCarouselHead(
+ var header: String? = null,
+ var content: String? = null,
+ var imageId: String? = null,
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt
new file mode 100644
index 0000000..c5e6088
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselItem.kt
@@ -0,0 +1,18 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsCarouselItem(
+ // FeedItem 전용 필드
+ var header: String? = null,
+ var content: String? = null,
+ // CommerceItem 전용 필드
+ var commerce: BmsCommerce? = null,
+ var additionalContent: String? = null,
+ // 공통 필드
+ var imageId: String? = null,
+ var imageLink: String? = null,
+ var buttons: List? = null,
+ var coupon: BmsCoupon? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt
new file mode 100644
index 0000000..fc545cf
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCarouselTail.kt
@@ -0,0 +1,11 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsCarouselTail(
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt
new file mode 100644
index 0000000..0ea09d9
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsChatBubbleType.kt
@@ -0,0 +1,49 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+/**
+ * BMS Free 채팅 버블 타입
+ */
+@Serializable
+enum class BmsChatBubbleType {
+ /**
+ * 텍스트 타입
+ */
+ TEXT,
+
+ /**
+ * 이미지 타입
+ */
+ IMAGE,
+
+ /**
+ * 와이드 타입
+ */
+ WIDE,
+
+ /**
+ * 와이드 리스트 타입
+ */
+ WIDE_ITEM_LIST,
+
+ /**
+ * 커머스 타입
+ */
+ COMMERCE,
+
+ /**
+ * 캐러셀 피드 타입
+ */
+ CAROUSEL_FEED,
+
+ /**
+ * 캐러셀 커머스 타입
+ */
+ CAROUSEL_COMMERCE,
+
+ /**
+ * 프리미엄 비디오 타입
+ */
+ PREMIUM_VIDEO
+}
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt
new file mode 100644
index 0000000..70811c2
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCommerce.kt
@@ -0,0 +1,12 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsCommerce(
+ var title: String? = null,
+ var regularPrice: Long? = null,
+ var discountPrice: Long? = null,
+ var discountRate: Int? = null,
+ var discountFixed: Long? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt
new file mode 100644
index 0000000..ed1b78d
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsCoupon.kt
@@ -0,0 +1,26 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+/**
+ * BMS Free 쿠폰 정보
+ *
+ * @property title 쿠폰 제목 (서버에서 필수).
+ * 허용 형식 (5가지만 가능):
+ * - "${숫자}원 할인 쿠폰" (1~99,999,999)
+ * - "${숫자}% 할인 쿠폰" (1~100)
+ * - "배송비 할인 쿠폰"
+ * - "${7자 이내} 무료 쿠폰"
+ * - "${7자 이내} UP 쿠폰"
+ * @property description 쿠폰 설명 (서버에서 필수).
+ * 길이 제한: WIDE/WIDE_ITEM_LIST는 최대 18자, 그 외는 최대 12자
+ */
+@Serializable
+data class BmsCoupon(
+ var title: String? = null,
+ var description: String? = null,
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt
new file mode 100644
index 0000000..20d8d21
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsMainWideItem.kt
@@ -0,0 +1,13 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsMainWideItem(
+ var title: String? = null,
+ var imageId: String? = null,
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt
new file mode 100644
index 0000000..407df95
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsSubWideItem.kt
@@ -0,0 +1,13 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsSubWideItem(
+ var title: String? = null,
+ var imageId: String? = null,
+ var linkMobile: String? = null,
+ var linkPc: String? = null,
+ var linkAndroid: String? = null,
+ var linkIos: String? = null
+)
diff --git a/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt
new file mode 100644
index 0000000..3acc3dc
--- /dev/null
+++ b/src/main/java/com/solapi/sdk/message/model/kakao/bms/BmsVideo.kt
@@ -0,0 +1,10 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class BmsVideo(
+ var videoUrl: String? = null,
+ var imageId: String? = null,
+ var imageLink: String? = null
+)
diff --git a/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt
new file mode 100644
index 0000000..85255d8
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/dto/request/DtoSerializationTest.kt
@@ -0,0 +1,273 @@
+package com.solapi.sdk.message.dto.request
+
+import com.solapi.sdk.message.lib.JsonSupport
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+import kotlinx.serialization.encodeToString
+
+class DtoSerializationTest {
+
+ @Test
+ fun `SendRequestConfig serializes scheduledDate as ISO-8601`() {
+ // Given
+ val config = SendRequestConfig(
+ appId = "test-app",
+ scheduledDate = Instant.parse("2024-06-15T14:30:00Z")
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(config)
+
+ // Then
+ assertTrue(json.contains("\"scheduledDate\":\"2024-06-15T14:30:00Z\""))
+ }
+
+ @Test
+ fun `SendRequestConfig deserializes scheduledDate from ISO-8601`() {
+ // Given
+ val json = """{"appId":"test-app","allowDuplicates":false,"showMessageList":false,"scheduledDate":"2024-06-15T14:30:00Z"}"""
+
+ // When
+ val config = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals("test-app", config.appId)
+ assertEquals(Instant.parse("2024-06-15T14:30:00Z"), config.scheduledDate)
+ }
+
+ @Test
+ fun `SendRequestConfig handles null scheduledDate`() {
+ // Given
+ val config = SendRequestConfig(appId = "test-app")
+
+ // When
+ val json = JsonSupport.json.encodeToString(config)
+ val restored = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertNull(restored.scheduledDate)
+ }
+
+ @Test
+ fun `MessageListRequest serializes date range as Instant`() {
+ // Given
+ val request = MessageListRequest(
+ to = "01012345678",
+ startDate = Instant.parse("2024-01-01T00:00:00Z"),
+ endDate = Instant.parse("2024-01-31T23:59:59Z")
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(request)
+
+ // Then
+ assertTrue(json.contains("\"startDate\":\"2024-01-01T00:00:00Z\""))
+ assertTrue(json.contains("\"endDate\":\"2024-01-31T23:59:59Z\""))
+ }
+
+ @Test
+ fun `MessageListRequest deserializes date range from Instant`() {
+ // Given
+ val json = """{"to":"01012345678","startDate":"2024-01-01T00:00:00Z","endDate":"2024-01-31T23:59:59Z"}"""
+
+ // When
+ val request = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(Instant.parse("2024-01-01T00:00:00Z"), request.startDate)
+ assertEquals(Instant.parse("2024-01-31T23:59:59Z"), request.endDate)
+ }
+
+ @Test
+ fun `MessageListBaseRequest serializes date range as Instant`() {
+ // Given
+ val request = MessageListBaseRequest(
+ to = "01012345678",
+ startDate = Instant.parse("2024-02-01T00:00:00Z"),
+ endDate = Instant.parse("2024-02-28T23:59:59Z")
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(request)
+
+ // Then
+ assertTrue(json.contains("\"startDate\":\"2024-02-01T00:00:00Z\""))
+ assertTrue(json.contains("\"endDate\":\"2024-02-28T23:59:59Z\""))
+ }
+
+ @Test
+ fun `MessageListBaseRequest deserializes date range from Instant`() {
+ // Given
+ val json = """{"to":"01012345678","startDate":"2024-02-01T00:00:00Z","endDate":"2024-02-28T23:59:59Z"}"""
+
+ // When
+ val request = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(Instant.parse("2024-02-01T00:00:00Z"), request.startDate)
+ assertEquals(Instant.parse("2024-02-28T23:59:59Z"), request.endDate)
+ }
+
+ @Test
+ fun `MultipleDetailMessageSendingRequest serializes scheduledDate`() {
+ // Given
+ val request = MultipleDetailMessageSendingRequest(
+ messages = emptyList(),
+ scheduledDate = Instant.parse("2024-03-15T09:00:00Z")
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(request)
+
+ // Then
+ assertTrue(json.contains("\"scheduledDate\":\"2024-03-15T09:00:00Z\""))
+ }
+
+ @Test
+ fun `MultipleDetailMessageSendingRequest round-trip preserves scheduledDate`() {
+ // Given
+ val original = MultipleDetailMessageSendingRequest(
+ messages = emptyList(),
+ scheduledDate = Instant.parse("2024-03-15T09:00:00Z"),
+ showMessageList = true
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(original)
+ val restored = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(original.scheduledDate, restored.scheduledDate)
+ assertEquals(original.showMessageList, restored.showMessageList)
+ }
+
+ @Test
+ fun `Instant with nanoseconds precision is preserved`() {
+ // Given
+ val preciseInstant = Instant.parse("2024-06-15T14:30:00.123456789Z")
+ val config = SendRequestConfig(scheduledDate = preciseInstant)
+
+ // When
+ val json = JsonSupport.json.encodeToString(config)
+ val restored = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(preciseInstant, restored.scheduledDate)
+ }
+
+ @Test
+ fun `SendRequestConfig setScheduledDateFromLocalDateTime with system default timezone`() {
+ // Given
+ val config = SendRequestConfig(appId = "test-app")
+ val localDateTime = java.time.LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ config.setScheduledDateFromLocalDateTime(localDateTime)
+
+ // Then
+ val expectedJavaInstant = localDateTime
+ .atZone(java.time.ZoneId.systemDefault())
+ .toInstant()
+ val actualJavaInstant = java.time.Instant.parse(config.scheduledDate.toString())
+ assertEquals(expectedJavaInstant, actualJavaInstant)
+ }
+
+ @Test
+ fun `SendRequestConfig setScheduledDateFromLocalDateTime with explicit UTC timezone`() {
+ // Given
+ val config = SendRequestConfig(appId = "test-app")
+ val localDateTime = java.time.LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ config.setScheduledDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ val expectedInstant = Instant.parse("2024-06-15T14:30:00Z")
+ assertEquals(expectedInstant, config.scheduledDate)
+ }
+
+ @Test
+ fun `SendRequestConfig backward compatibility - existing Instant API still works`() {
+ // Given
+ val instant = Instant.parse("2024-06-15T14:30:00Z")
+
+ // When
+ val config = SendRequestConfig(
+ appId = "test-app",
+ scheduledDate = instant
+ )
+
+ // Then
+ assertEquals(instant, config.scheduledDate)
+ val json = JsonSupport.json.encodeToString(config)
+ assertTrue(json.contains("\"scheduledDate\":\"2024-06-15T14:30:00Z\""))
+ }
+
+ @Test
+ fun `MessageListRequest setStartDateFromLocalDateTime works`() {
+ // Given
+ val request = MessageListRequest()
+ val localDateTime = java.time.LocalDateTime.of(2024, 1, 1, 0, 0, 0)
+
+ // When
+ request.setStartDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ assertEquals(Instant.parse("2024-01-01T00:00:00Z"), request.startDate)
+ }
+
+ @Test
+ fun `MessageListRequest setEndDateFromLocalDateTime works`() {
+ // Given
+ val request = MessageListRequest()
+ val localDateTime = java.time.LocalDateTime.of(2024, 12, 31, 23, 59, 59)
+
+ // When
+ request.setEndDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ assertEquals(Instant.parse("2024-12-31T23:59:59Z"), request.endDate)
+ }
+
+ @Test
+ fun `MessageListBaseRequest setStartDateFromLocalDateTime works`() {
+ // Given
+ val request = MessageListBaseRequest()
+ val localDateTime = java.time.LocalDateTime.of(2024, 2, 1, 0, 0, 0)
+
+ // When
+ request.setStartDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ assertEquals(Instant.parse("2024-02-01T00:00:00Z"), request.startDate)
+ }
+
+ @Test
+ fun `MessageListBaseRequest setEndDateFromLocalDateTime works`() {
+ // Given
+ val request = MessageListBaseRequest()
+ val localDateTime = java.time.LocalDateTime.of(2024, 2, 28, 23, 59, 59)
+
+ // When
+ request.setEndDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ assertEquals(Instant.parse("2024-02-28T23:59:59Z"), request.endDate)
+ }
+
+ @Test
+ fun `MultipleDetailMessageSendingRequest setScheduledDateFromLocalDateTime works`() {
+ // Given
+ val request = MultipleDetailMessageSendingRequest(messages = emptyList())
+ val localDateTime = java.time.LocalDateTime.of(2024, 3, 15, 9, 0, 0)
+
+ // When
+ request.setScheduledDateFromLocalDateTime(localDateTime, java.time.ZoneOffset.UTC)
+
+ // Then
+ assertEquals(Instant.parse("2024-03-15T09:00:00Z"), request.scheduledDate)
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt b/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt
new file mode 100644
index 0000000..4ad07e6
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/dto/request/kakao/KakaoTemplateDateQueryTest.kt
@@ -0,0 +1,79 @@
+package com.solapi.sdk.message.dto.request.kakao
+
+import java.time.LocalDateTime
+import java.time.ZoneOffset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+class KakaoTemplateDateQueryTest {
+
+ @Test
+ fun `secondary constructor creates query from LocalDateTime`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ val query = KakaoTemplateDateQuery(
+ localDateTime,
+ KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS,
+ ZoneOffset.UTC
+ )
+
+ // Then
+ assertEquals(Instant.parse("2024-06-15T14:30:00Z"), query.date)
+ assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS, query.queryCondition)
+ }
+
+ @Test
+ fun `fromLocalDateTime factory method creates query`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ val query = KakaoTemplateDateQuery.fromLocalDateTime(
+ localDateTime,
+ KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.GREATER_THEN,
+ ZoneOffset.UTC
+ )
+
+ // Then
+ assertEquals(Instant.parse("2024-06-15T14:30:00Z"), query.date)
+ assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.GREATER_THEN, query.queryCondition)
+ }
+
+ @Test
+ fun `existing Instant constructor still works - backward compatibility`() {
+ // Given
+ val instant = Instant.parse("2024-06-15T14:30:00Z")
+
+ // When
+ val query = KakaoTemplateDateQuery(
+ instant,
+ KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.LESS_THEN
+ )
+
+ // Then
+ assertEquals(instant, query.date)
+ assertEquals(KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.LESS_THEN, query.queryCondition)
+ }
+
+ @Test
+ fun `secondary constructor uses system default timezone when not specified`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ val query = KakaoTemplateDateQuery(
+ localDateTime,
+ KakaoTemplateDateQuery.KakaoAlimtalkTemplateDateQueryCondition.EQUALS
+ )
+
+ // Then
+ val expectedJavaInstant = localDateTime
+ .atZone(java.time.ZoneId.systemDefault())
+ .toInstant()
+ val actualJavaInstant = java.time.Instant.parse(query.date.toString())
+ assertEquals(expectedJavaInstant, actualJavaInstant)
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt
new file mode 100644
index 0000000..15f18f0
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BalanceE2ETest.kt
@@ -0,0 +1,42 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+/**
+ * 잔액 조회 E2E 테스트
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ */
+class BalanceE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `잔액 조회`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // When
+ val balance = messageService!!.getBalance()
+
+ // Then
+ assertNotNull(balance)
+ println("잔액 조회 성공")
+ println(" balance: ${balance.balance}")
+ println(" point: ${balance.point}")
+ }
+
+ @Test
+ fun `일일 발송량 한도 조회`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // When
+ val quota = messageService!!.getQuota()
+
+ // Then
+ assertNotNull(quota)
+ println("일일 발송량 한도 조회 성공")
+ println(" quota: $quota")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt
new file mode 100644
index 0000000..a8c44bd
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/BmsFreeE2ETest.kt
@@ -0,0 +1,859 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.lib.BmsTestUtils
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.StorageType
+import com.solapi.sdk.message.model.kakao.KakaoOption
+import com.solapi.sdk.message.model.kakao.bms.BmsCoupon
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * BMS Free 발송 E2E 테스트
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID (선택)
+ */
+class BmsFreeE2ETest : BaseE2ETest() {
+
+ private fun createBmsFreeMessage(kakaoOption: KakaoOption, text: String? = null): Message = Message(
+ type = MessageType.BMS_FREE,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = text,
+ kakaoOptions = kakaoOption
+ )
+
+ // ==================== TEXT 타입 테스트 ====================
+
+ @Test
+ fun `TEXT 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val bmsOption = BmsTestUtils.createTextBmsOption(
+ content = "BMS Free TEXT 최소 구조 테스트"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("TEXT 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `TEXT 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("바로가기", "https://example.com"),
+ BmsTestUtils.createChannelAddButton("채널 추가")
+ )
+
+ val coupon = BmsTestUtils.createPercentCoupon(10, "할인쿠폰")
+
+ val bmsOption = BmsTestUtils.createTextBmsOptionFull(
+ content = "BMS Free TEXT 전체 필드 테스트",
+ buttons = buttons,
+ coupon = coupon,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("TEXT 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== IMAGE 타입 테스트 ====================
+
+ @Test
+ fun `IMAGE 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS, "test-image.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val bmsOption = BmsTestUtils.createImageBmsOption(
+ imageId = imageId,
+ content = "BMS Free IMAGE 최소 구조 테스트"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("IMAGE 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `IMAGE 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS, "test-image.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("자세히 보기", "https://example.com"),
+ BmsTestUtils.createAppLinkButton("앱 열기", "intent://main", "iosapp://main")
+ )
+
+ val coupon = BmsTestUtils.createWonCoupon(5000, "5000원 할인")
+
+ val bmsOption = BmsTestUtils.createImageBmsOptionFull(
+ imageId = imageId,
+ imageLink = "https://example.com/image",
+ buttons = buttons,
+ coupon = coupon,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption, text = "BMS Free IMAGE 전체 필드 테스트")
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("IMAGE 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== WIDE 타입 테스트 ====================
+
+ @Test
+ fun `WIDE 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_WIDE, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val bmsOption = BmsTestUtils.createWideBmsOption(
+ imageId = imageId,
+ content = "BMS Free WIDE 최소 구조 테스트"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("WIDE 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `WIDE 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_WIDE, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("바로가기", "https://example.com"),
+ BmsTestUtils.createBotKeywordButton("문의하기")
+ )
+
+ val coupon = BmsTestUtils.createShippingCoupon("무료배송")
+
+ val bmsOption = BmsTestUtils.createWideBmsOptionFull(
+ imageId = imageId,
+ imageLink = "https://example.com/wide",
+ buttons = buttons,
+ coupon = coupon,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption, text = "BMS Free WIDE 전체 필드 테스트")
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("WIDE 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== WIDE_ITEM_LIST 타입 테스트 ====================
+
+ @Test
+ fun `WIDE_ITEM_LIST 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val mainImageId = uploadImage(StorageType.BMS_WIDE_MAIN_ITEM_LIST, "test-image-2to1.png")
+ val subImageId = uploadImage(StorageType.BMS_WIDE_SUB_ITEM_LIST, "test-image.png")
+ if (mainImageId == null || subImageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val mainWideItem = BmsTestUtils.createMainWideItem(
+ imageId = mainImageId,
+ title = "메인 아이템"
+ )
+
+ // WIDE_ITEM_LIST는 최소 3개의 서브 아이템이 필요합니다
+ val subWideItemList = listOf(
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 1"),
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2"),
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 3")
+ )
+
+ val bmsOption = BmsTestUtils.createWideItemListBmsOption(
+ mainWideItem = mainWideItem,
+ subWideItemList = subWideItemList
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("WIDE_ITEM_LIST 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `WIDE_ITEM_LIST 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val mainImageId = uploadImage(StorageType.BMS_WIDE_MAIN_ITEM_LIST, "test-image-2to1.png")
+ val subImageId = uploadImage(StorageType.BMS_WIDE_SUB_ITEM_LIST, "test-image.png")
+ if (mainImageId == null || subImageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val mainWideItem = BmsTestUtils.createMainWideItem(
+ imageId = mainImageId,
+ title = "메인 아이템 타이틀",
+ linkMobile = "https://example.com/main"
+ )
+
+ val subWideItemList = listOf(
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 1", "https://example.com/sub1"),
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 2", "https://example.com/sub2"),
+ BmsTestUtils.createSubWideItem(subImageId, "서브 아이템 3", "https://example.com/sub3")
+ )
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("전체보기", "https://example.com/all")
+ )
+
+ val coupon = BmsTestUtils.createFreeCoupon("커피", "무료쿠폰")
+
+ val bmsOption = BmsTestUtils.createWideItemListBmsOptionFull(
+ mainWideItem = mainWideItem,
+ subWideItemList = subWideItemList,
+ header = "WIDE_ITEM_LIST 헤더",
+ buttons = buttons,
+ coupon = coupon,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("WIDE_ITEM_LIST 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== COMMERCE 타입 테스트 ====================
+
+ @Test
+ fun `COMMERCE 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val commerce = BmsTestUtils.createCommerce(
+ title = "테스트 상품",
+ regularPrice = 50000
+ )
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("구매하기", "https://example.com/buy")
+ )
+
+ val bmsOption = BmsTestUtils.createCommerceBmsOption(
+ imageId = imageId,
+ commerce = commerce,
+ buttons = buttons
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("COMMERCE 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `COMMERCE 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val commerce = BmsTestUtils.createCommerce(
+ title = "프리미엄 상품",
+ regularPrice = 129000,
+ discountPrice = 99000,
+ discountRate = 23
+ )
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("구매하기", "https://example.com/buy"),
+ BmsTestUtils.createWebLinkButton("장바구니", "https://example.com/cart")
+ )
+
+ val coupon = BmsTestUtils.createUpCoupon("포인트", "2배 적립")
+
+ val bmsOption = BmsTestUtils.createCommerceBmsOptionFull(
+ imageId = imageId,
+ commerce = commerce,
+ buttons = buttons,
+ imageLink = "https://example.com/product",
+ additionalContent = "무료배송 | 오늘 출발",
+ coupon = coupon,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("COMMERCE 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== CAROUSEL_FEED 타입 테스트 ====================
+
+ @Test
+ fun `CAROUSEL_FEED 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_CAROUSEL_FEED_LIST, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val itemButtons = listOf(
+ BmsTestUtils.createWebLinkButton("바로가기", "https://example.com")
+ )
+
+ val carouselItems = listOf(
+ BmsTestUtils.createCarouselFeedItem(imageId, "아이템 1", "내용 1", buttons = itemButtons),
+ BmsTestUtils.createCarouselFeedItem(imageId, "아이템 2", "내용 2", buttons = itemButtons)
+ )
+
+ val bmsOption = BmsTestUtils.createCarouselFeedBmsOption(
+ carouselItems = carouselItems
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("CAROUSEL_FEED 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `CAROUSEL_FEED 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_CAROUSEL_FEED_LIST, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val itemButtons = listOf(
+ BmsTestUtils.createWebLinkButton("자세히", "https://example.com/detail")
+ )
+
+ val carouselItems = listOf(
+ BmsTestUtils.createCarouselFeedItem(
+ imageId = imageId,
+ header = "피드 아이템 1",
+ content = "피드 내용 1",
+ imageLink = "https://example.com/1",
+ buttons = itemButtons
+ ),
+ BmsTestUtils.createCarouselFeedItem(
+ imageId = imageId,
+ header = "피드 아이템 2",
+ content = "피드 내용 2",
+ imageLink = "https://example.com/2",
+ buttons = itemButtons,
+ coupon = BmsTestUtils.createPercentCoupon(5, "할인")
+ ),
+ BmsTestUtils.createCarouselFeedItem(
+ imageId = imageId,
+ header = "피드 아이템 3",
+ content = "피드 내용 3",
+ imageLink = "https://example.com/3",
+ buttons = itemButtons
+ )
+ )
+
+ val tail = BmsTestUtils.createCarouselTail(
+ linkMobile = "https://example.com/more",
+ linkPc = "https://example.com/more"
+ )
+
+ val bmsOption = BmsTestUtils.createCarouselFeedBmsOptionFull(
+ carouselItems = carouselItems,
+ tail = tail,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("CAROUSEL_FEED 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== CAROUSEL_COMMERCE 타입 테스트 ====================
+
+ @Test
+ fun `CAROUSEL_COMMERCE 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_CAROUSEL_COMMERCE_LIST, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val itemButtons = listOf(
+ BmsTestUtils.createWebLinkButton("구매하기", "https://example.com")
+ )
+
+ val carouselItems = listOf(
+ BmsTestUtils.createCarouselCommerceItem(
+ imageId = imageId,
+ commerce = BmsTestUtils.createCommerce("상품 1", 30000),
+ buttons = itemButtons
+ ),
+ BmsTestUtils.createCarouselCommerceItem(
+ imageId = imageId,
+ commerce = BmsTestUtils.createCommerce("상품 2", 40000),
+ buttons = itemButtons
+ )
+ )
+
+ val bmsOption = BmsTestUtils.createCarouselCommerceBmsOption(
+ carouselItems = carouselItems
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("CAROUSEL_COMMERCE 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `CAROUSEL_COMMERCE 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS_CAROUSEL_COMMERCE_LIST, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val head = BmsTestUtils.createCarouselHead(
+ header = "베스트 상품",
+ content = "이번 주 인기 상품",
+ imageId = imageId,
+ linkMobile = "https://example.com/best"
+ )
+
+ val itemButtons = listOf(
+ BmsTestUtils.createWebLinkButton("구매", "https://example.com/buy")
+ )
+
+ val carouselItems = listOf(
+ BmsTestUtils.createCarouselCommerceItem(
+ imageId = imageId,
+ commerce = BmsTestUtils.createCommerce("상품 A", 50000, 40000, 20),
+ additionalContent = "무료배송",
+ imageLink = "https://example.com/a",
+ buttons = itemButtons
+ ),
+ BmsTestUtils.createCarouselCommerceItem(
+ imageId = imageId,
+ commerce = BmsTestUtils.createCommerce("상품 B", 80000, 60000, 25),
+ additionalContent = "오늘 출발",
+ imageLink = "https://example.com/b",
+ buttons = itemButtons,
+ coupon = BmsTestUtils.createWonCoupon(3000, "할인")
+ ),
+ BmsTestUtils.createCarouselCommerceItem(
+ imageId = imageId,
+ commerce = BmsTestUtils.createCommerce("상품 C", 35000),
+ additionalContent = "인기상품",
+ imageLink = "https://example.com/c",
+ buttons = itemButtons
+ )
+ )
+
+ val tail = BmsTestUtils.createCarouselTail(
+ linkMobile = "https://example.com/all",
+ linkPc = "https://example.com/all"
+ )
+
+ val bmsOption = BmsTestUtils.createCarouselCommerceBmsOptionFull(
+ carouselItems = carouselItems,
+ tail = tail,
+ head = head,
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("CAROUSEL_COMMERCE 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== PREMIUM_VIDEO 타입 테스트 ====================
+
+ @Test
+ fun `PREMIUM_VIDEO 타입 - 최소 구조`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.KAKAO, "test-image.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val video = BmsTestUtils.createVideo(
+ videoUrl = "https://tv.kakao.com/v/460734285",
+ imageId = imageId
+ )
+
+ val bmsOption = BmsTestUtils.createPremiumVideoBmsOption(
+ video = video,
+ content = "BMS Free PREMIUM_VIDEO 최소 구조 테스트"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("PREMIUM_VIDEO 최소 구조 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `PREMIUM_VIDEO 타입 - 전체 필드`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.KAKAO, "test-image.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val video = BmsTestUtils.createVideo(
+ videoUrl = "https://tv.kakao.com/v/460734285",
+ imageId = imageId,
+ imageLink = "https://example.com/video"
+ )
+
+ val buttons = listOf(
+ BmsTestUtils.createWebLinkButton("영상 보기", "https://tv.kakao.com/v/460734285")
+ )
+
+ val bmsOption = BmsTestUtils.createPremiumVideoBmsOptionFull(
+ video = video,
+ content = "BMS Free PREMIUM_VIDEO 전체 필드 테스트",
+ header = "PREMIUM_VIDEO 헤더",
+ buttons = buttons,
+ coupon = BmsTestUtils.createPercentCoupon(10, "프리미엄 비디오 쿠폰"),
+ adult = false
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("PREMIUM_VIDEO 전체 필드 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== Error Cases 테스트 ====================
+
+ @Test
+ fun `IMAGE without imageId - 필수 필드 누락`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val bmsOption = BmsTestUtils.createImageBmsOption(
+ imageId = "", // 빈 이미지 ID
+ content = "이미지 없는 IMAGE 타입"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "이미지 ID 없이 IMAGE 타입 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `COMMERCE without buttons - 버튼 없이 발송 허용`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.BMS, "test-image-2to1.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val commerce = BmsTestUtils.createCommerce("상품", 10000)
+
+ val bmsOption = BmsTestUtils.createCommerceBmsOption(
+ imageId = imageId,
+ commerce = commerce,
+ buttons = emptyList()
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+ val response = messageService!!.send(message)
+
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("COMMERCE 버튼 없이 발송 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `PREMIUM_VIDEO with invalid videoUrl`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val imageId = uploadImage(StorageType.KAKAO, "test-image.png")
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val video = BmsTestUtils.createVideo(
+ videoUrl = "https://invalid-video-url.com/video", // 잘못된 비디오 URL (카카오 TV가 아님)
+ imageId = imageId
+ )
+
+ val bmsOption = BmsTestUtils.createPremiumVideoBmsOption(
+ video = video,
+ content = "잘못된 비디오 URL 테스트"
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "잘못된 비디오 URL로 PREMIUM_VIDEO 타입 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `Invalid coupon title format`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val invalidCoupon = BmsCoupon(
+ title = "잘못된 쿠폰 제목",
+ description = "설명"
+ )
+
+ // TEXT 타입은 adult, content, buttons, coupon만 지원
+ val bmsOption = BmsTestUtils.createTextBmsOption(
+ content = "잘못된 쿠폰 테스트"
+ ).copy(
+ buttons = listOf(BmsTestUtils.createWebLinkButton()),
+ coupon = invalidCoupon
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "잘못된 쿠폰 제목 형식으로 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `CAROUSEL_FEED without carousel`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ val bmsOption = BmsTestUtils.createCarouselFeedBmsOption(
+ carouselItems = emptyList() // 빈 캐러셀
+ )
+
+ val kakaoOption = KakaoOption(
+ pfId = pfId,
+ bms = bmsOption
+ )
+
+ val message = createBmsFreeMessage(kakaoOption)
+
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "빈 캐러셀로 CAROUSEL_FEED 타입 발송 시 에러가 발생해야 함")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt
new file mode 100644
index 0000000..620e664
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/CustomFieldsE2ETest.kt
@@ -0,0 +1,132 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+/**
+ * Custom Fields E2E 테스트
+ *
+ * Custom Fields는 메시지에 사용자 정의 데이터를 추가할 수 있는 기능입니다.
+ * 발송 후 메시지 조회 시에도 해당 필드가 포함됩니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class CustomFieldsE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `Custom Fields 포함 발송`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val customFields = mutableMapOf(
+ "orderId" to "ORD-12345",
+ "userId" to "USER-67890",
+ "category" to "notification"
+ )
+
+ val message = E2ETestUtils.createMessageWithCustomFields(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] Custom Fields 테스트입니다.",
+ customFields = customFields
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("Custom Fields 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" customFields: $customFields")
+ }
+
+ @Test
+ fun `Custom Fields 다양한 값`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 특수문자, 유니코드 포함
+ val customFields = mutableMapOf(
+ "key_with_underscore" to "value1",
+ "한글키" to "한글값",
+ "emoji" to "🚀🎉",
+ "special" to "!@#\$%^&*()",
+ "number" to "12345",
+ "empty" to ""
+ )
+
+ val message = E2ETestUtils.createMessageWithCustomFields(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] Custom Fields 다양한 값 테스트",
+ customFields = customFields
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("Custom Fields 다양한 값 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `Custom Fields 단일 필드`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 하나의 커스텀 필드만 사용
+ val customFields = mutableMapOf(
+ "trackingId" to "TRK-${System.currentTimeMillis()}"
+ )
+
+ val message = E2ETestUtils.createMessageWithCustomFields(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] Custom Fields 단일 필드 테스트",
+ customFields = customFields
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("Custom Fields 단일 필드 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" trackingId: ${customFields["trackingId"]}")
+ }
+
+ @Test
+ fun `Custom Fields 긴 값`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 긴 문자열 값
+ val longValue = "A".repeat(200)
+ val customFields = mutableMapOf(
+ "longField" to longValue
+ )
+
+ val message = E2ETestUtils.createMessageWithCustomFields(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] Custom Fields 긴 값 테스트",
+ customFields = customFields
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("Custom Fields 긴 값 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" longField 길이: ${longValue.length}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt
new file mode 100644
index 0000000..107be87
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/DuplicateHandlingE2ETest.kt
@@ -0,0 +1,192 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.dto.request.SendRequestConfig
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * 중복 수신번호 처리 E2E 테스트
+ *
+ * SendRequestConfig의 allowDuplicates 옵션을 통해 동일 수신번호로
+ * 여러 메시지 발송 시 중복 허용 여부를 제어할 수 있습니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class DuplicateHandlingE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `중복 수신번호 허용`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 동일 수신번호로 여러 메시지 생성
+ val messages = listOf(
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 허용 테스트 1/3"
+ ),
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 허용 테스트 2/3"
+ ),
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 허용 테스트 3/3"
+ )
+ )
+
+ val config = SendRequestConfig(
+ allowDuplicates = true
+ )
+
+ // When
+ val response = messageService!!.send(messages, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("중복 수신번호 허용 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" 발송 요청: ${messages.size}건, 접수: ${response.groupInfo?.count?.total ?: 0}건")
+ }
+
+ @Test
+ fun `중복 수신번호 비허용`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 동일 수신번호로 여러 메시지 생성 (중복 비허용)
+ val messages = listOf(
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 비허용 테스트 1/3"
+ ),
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 비허용 테스트 2/3"
+ ),
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 중복 비허용 테스트 3/3"
+ )
+ )
+
+ val config = SendRequestConfig(
+ allowDuplicates = false
+ )
+
+ // When
+ val response = messageService!!.send(messages, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("중복 수신번호 비허용 발송 - groupId: ${response.groupInfo?.groupId}")
+ println(" 발송 요청: ${messages.size}건, 접수: ${response.groupInfo?.count?.total ?: 0}건")
+ // 중복 비허용 시 동일 수신번호는 1건만 접수됨
+ }
+
+ @Test
+ fun `showMessageList 옵션`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - showMessageList = true로 메시지 목록 포함
+ val message = E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] showMessageList 옵션 테스트"
+ )
+
+ val config = SendRequestConfig(
+ showMessageList = true
+ )
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("showMessageList 옵션 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" messageList 포함 여부: ${response.messageList.isNotEmpty()}")
+ response.messageList.forEach { msg ->
+ println(" - messageId: ${msg.messageId}")
+ }
+ }
+
+ @Test
+ fun `showMessageList false 옵션`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - showMessageList = false (기본값)
+ val message = E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] showMessageList false 테스트"
+ )
+
+ val config = SendRequestConfig(
+ showMessageList = false
+ )
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("showMessageList false 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `중복 허용과 showMessageList 조합`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 두 옵션 모두 활성화
+ val messages = listOf(
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 조합 테스트 1/2"
+ ),
+ E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 조합 테스트 2/2"
+ )
+ )
+
+ val config = SendRequestConfig(
+ allowDuplicates = true,
+ showMessageList = true
+ )
+
+ // When
+ val response = messageService!!.send(messages, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("중복 허용 + showMessageList 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" 발송 요청: ${messages.size}건")
+ println(" messageList 건수: ${response.messageList.size}")
+
+ // 중복 허용 시 요청한 메시지 수만큼 messageList에 포함되어야 함
+ if (response.messageList.isNotEmpty()) {
+ assertTrue(
+ response.messageList.size == messages.size,
+ "중복 허용 시 요청 메시지 수와 응답 메시지 수가 동일해야 함"
+ )
+ }
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt
new file mode 100644
index 0000000..784ae14
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoAlimtalkE2ETest.kt
@@ -0,0 +1,153 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.kakao.KakaoOption
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * 카카오 알림톡 (ATA) E2E 테스트
+ *
+ * 알림톡은 카카오톡으로 발송되는 정보성 메시지입니다.
+ * 사전에 등록된 템플릿을 사용해야 합니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID
+ */
+class KakaoAlimtalkE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `알림톡 발송 - 기본 템플릿`() {
+ if (!assumeKakaoTemplateConfigured()) return
+
+ // Given
+ val message = E2ETestUtils.createAlimtalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ pfId = pfId!!,
+ templateId = templateId!!
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("알림톡 기본 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `알림톡 발송 - 변수 치환`() {
+ if (!assumeKakaoTemplateConfigured()) return
+
+ // Given - 템플릿에 맞는 변수 설정 (실제 템플릿에 따라 변수명 조정 필요)
+ val variables = mapOf(
+ "name" to "테스트 사용자",
+ "code" to "123456"
+ )
+
+ val message = E2ETestUtils.createAlimtalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ pfId = pfId!!,
+ templateId = templateId!!,
+ variables = variables
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("알림톡 변수 치환 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `알림톡 발송 - 잘못된 템플릿 ID`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given - 존재하지 않는 템플릿 ID
+ val message = Message(
+ type = MessageType.ATA,
+ from = senderNumber,
+ to = testPhoneNumber,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ templateId = "invalid-template-id-12345"
+ )
+ )
+
+ // When & Then
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "잘못된 템플릿 ID로 알림톡 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `알림톡 대체 발송 비활성화`() {
+ if (!assumeKakaoTemplateConfigured()) return
+
+ // Given - disableSms = true
+ val message = E2ETestUtils.createAlimtalkMessageWithoutFallback(
+ from = senderNumber,
+ to = testPhoneNumber,
+ pfId = pfId!!,
+ templateId = templateId!!
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("알림톡 대체 발송 비활성화 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `알림톡 발송 - 변수 자동 포맷팅`() {
+ if (!assumeKakaoTemplateConfigured()) return
+
+ // Given - #{} 없이 변수 키 지정 (KakaoOption에서 자동 포맷팅)
+ val variables = mapOf(
+ "name" to "자동포맷테스트",
+ "#{code}" to "999999" // 이미 포맷된 키도 허용
+ )
+
+ val message = Message(
+ type = MessageType.ATA,
+ from = senderNumber,
+ to = testPhoneNumber,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ templateId = templateId,
+ variables = variables
+ )
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("알림톡 변수 자동 포맷팅 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt
new file mode 100644
index 0000000..24914b9
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/KakaoFriendTalkE2ETest.kt
@@ -0,0 +1,313 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.model.StorageType
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+/**
+ * 카카오 친구톡 (CTA/CTI) E2E 테스트
+ *
+ * 친구톡은 카카오톡 채널 친구에게 발송하는 광고성 메시지입니다.
+ * CTA: 텍스트 친구톡
+ * CTI: 이미지 친구톡
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ */
+class KakaoFriendTalkE2ETest : BaseE2ETest() {
+
+ /**
+ * 카카오용 이미지 업로드
+ */
+ private fun uploadKakaoImage(filename: String = "test-image.png"): String? {
+ val imageUrl = javaClass.classLoader.getResource("images/$filename")
+ if (imageUrl == null) {
+ println("테스트 이미지가 없어 건너뜁니다: images/$filename")
+ return null
+ }
+ val file = File(imageUrl.toURI())
+ return messageService?.uploadFile(file, StorageType.KAKAO)
+ }
+
+ // ==================== CTA (텍스트 친구톡) 테스트 ====================
+
+ @Test
+ fun `친구톡 발송 - 텍스트만`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 친구톡 텍스트 메시지입니다.",
+ pfId = pfId!!
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 텍스트 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 웹링크 버튼`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val buttons = listOf(
+ E2ETestUtils.createWebLinkButton(
+ buttonName = "바로가기",
+ linkMo = "https://example.com"
+ )
+ )
+
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 웹링크 버튼 포함 친구톡입니다.",
+ pfId = pfId!!,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 웹링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 앱링크 버튼`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val buttons = listOf(
+ E2ETestUtils.createAppLinkButton(
+ buttonName = "앱 열기",
+ linkAnd = "intent://main#Intent;scheme=example;package=com.example;end",
+ linkIos = "exampleapp://main"
+ )
+ )
+
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 앱링크 버튼 포함 친구톡입니다.",
+ pfId = pfId!!,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 앱링크 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 봇키워드 버튼`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val buttons = listOf(
+ E2ETestUtils.createBotKeywordButton(buttonName = "문의하기")
+ )
+
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 봇키워드 버튼 포함 친구톡입니다.",
+ pfId = pfId!!,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 봇키워드 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 메시지전달 버튼`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val buttons = listOf(
+ E2ETestUtils.createMessageDeliveryButton(buttonName = "전달하기")
+ )
+
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 메시지전달 버튼 포함 친구톡입니다.",
+ pfId = pfId!!,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 메시지전달 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 광고 플래그`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given - adFlag = true
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 광고성 친구톡입니다.",
+ pfId = pfId!!,
+ adFlag = true
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 광고 플래그 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 발송 - 다중 버튼`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given - 여러 버튼 조합
+ val buttons = listOf(
+ E2ETestUtils.createWebLinkButton("홈페이지", "https://example.com"),
+ E2ETestUtils.createBotKeywordButton("상담 요청")
+ )
+
+ val message = E2ETestUtils.createFriendTalkMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 다중 버튼 친구톡입니다.",
+ pfId = pfId!!,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 다중 버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ // ==================== CTI (이미지 친구톡) 테스트 ====================
+
+ @Test
+ fun `친구톡 이미지 발송`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val imageId = uploadKakaoImage()
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val message = E2ETestUtils.createFriendTalkImageMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 이미지 친구톡입니다.",
+ pfId = pfId!!,
+ imageId = imageId
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 이미지 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 이미지 발송 - 버튼 포함`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val imageId = uploadKakaoImage()
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val buttons = listOf(
+ E2ETestUtils.createWebLinkButton("자세히 보기", "https://example.com")
+ )
+
+ val message = E2ETestUtils.createFriendTalkImageMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 버튼 포함 이미지 친구톡입니다.",
+ pfId = pfId!!,
+ imageId = imageId,
+ buttons = buttons
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 이미지+버튼 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `친구톡 이미지 발송 - 광고 플래그`() {
+ if (!assumeKakaoEnvironmentConfigured()) return
+
+ // Given
+ val imageId = uploadKakaoImage()
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val message = E2ETestUtils.createFriendTalkImageMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 광고성 이미지 친구톡입니다.",
+ pfId = pfId!!,
+ imageId = imageId,
+ adFlag = true
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("친구톡 이미지 광고 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt
new file mode 100644
index 0000000..58881cf
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/LmsE2ETest.kt
@@ -0,0 +1,114 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+/**
+ * LMS 발송 E2E 테스트
+ *
+ * LMS는 80바이트 이상 2000바이트 미만의 장문 메시지입니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class LmsE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `LMS 단건 발송 - 명시적 타입 지정`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - MessageType.LMS 명시적 지정
+ val message = E2ETestUtils.createLmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = E2ETestUtils.generateLongText(100),
+ subject = "LMS 제목"
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("LMS 단건 발송 (명시적 타입) 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `LMS 자동 감지 - autoTypeDetect`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 타입 지정 없이 80바이트 초과 메시지 (자동으로 LMS 변환)
+ val longText = E2ETestUtils.generateLongText(100)
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = longText,
+ autoTypeDetect = true
+ )
+
+ println("메시지 바이트 길이: ${longText.toByteArray(Charsets.UTF_8).size}")
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("LMS 자동 감지 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `LMS 최대 길이 테스트`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 약 2000바이트에 가까운 긴 메시지
+ val maxLengthText = E2ETestUtils.generateMaxLengthLmsText()
+ val message = Message(
+ type = MessageType.LMS,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = maxLengthText,
+ subject = "LMS 최대 길이 테스트"
+ )
+
+ println("메시지 바이트 길이: ${maxLengthText.toByteArray(Charsets.UTF_8).size}")
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("LMS 최대 길이 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `LMS 발송 - 제목 포함`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ type = MessageType.LMS,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = E2ETestUtils.generateLongText(150),
+ subject = "[SDK 테스트] LMS 제목입니다"
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("LMS 제목 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt
new file mode 100644
index 0000000..da99ede
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/MessageListE2ETest.kt
@@ -0,0 +1,220 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.dto.request.MessageListRequest
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.model.MessageStatusType
+import java.time.LocalDateTime
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * 메시지 목록 조회 E2E 테스트
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class MessageListE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `메시지 목록 조회 - 기본`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // When - 파라미터 없이 조회
+ val response = messageService!!.getMessageList()
+
+ // Then
+ assertNotNull(response)
+ println("메시지 목록 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 발신번호 필터`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val request = MessageListRequest(
+ from = senderNumber
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("발신번호 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 수신번호 필터`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val request = MessageListRequest(
+ to = testPhoneNumber
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("수신번호 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 메시지 타입 필터`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - SMS 타입만 조회
+ val request = MessageListRequest(
+ type = "SMS"
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("메시지 타입 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 상태 필터 (완료)`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 발송 완료 메시지만 조회
+ val request = MessageListRequest(
+ status = MessageStatusType.COMPLETE
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("상태 필터 (완료) 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 상태 필터 (대기)`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 발송 대기 메시지만 조회
+ val request = MessageListRequest(
+ status = MessageStatusType.PENDING
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("상태 필터 (대기) 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 날짜 범위 필터`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 최근 7일간 메시지 조회
+ val request = MessageListRequest()
+ request.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(7))
+ request.setEndDateFromLocalDateTime(LocalDateTime.now())
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("날짜 범위 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 페이지네이션`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 첫 페이지 (limit = 5)
+ val firstPageRequest = MessageListRequest(
+ limit = 5
+ )
+
+ // When - 첫 페이지 조회
+ val firstPageResponse = messageService!!.getMessageList(firstPageRequest)
+
+ // Then
+ assertNotNull(firstPageResponse)
+ println("첫 페이지 조회 성공 - 조회 건수: ${firstPageResponse.messageList?.size ?: 0}")
+
+ // Given - 다음 페이지 (startKey 사용)
+ // messageList는 Map이므로 마지막 키를 가져옴
+ val lastMessageId = firstPageResponse.messageList?.keys?.lastOrNull()
+ if (lastMessageId != null) {
+ val secondPageRequest = MessageListRequest(
+ limit = 5,
+ startKey = lastMessageId
+ )
+
+ // When - 두 번째 페이지 조회
+ val secondPageResponse = messageService!!.getMessageList(secondPageRequest)
+
+ // Then
+ assertNotNull(secondPageResponse)
+ println("두 번째 페이지 조회 성공 - 조회 건수: ${secondPageResponse.messageList?.size ?: 0}")
+ }
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 특정 messageIds`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 먼저 메시지 목록을 조회하여 messageId 획득
+ // messageList는 Map이므로 keys가 messageId
+ val initialResponse = messageService!!.getMessageList(MessageListRequest(limit = 3))
+ val messageIds = initialResponse?.messageList?.keys?.toList()
+
+ if (messageIds.isNullOrEmpty()) {
+ println("조회할 메시지가 없어 테스트를 건너뜁니다.")
+ return
+ }
+
+ // Given - 특정 messageIds로 조회
+ val request = MessageListRequest(
+ messageIds = messageIds
+ )
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ assertTrue(
+ (response.messageList?.size ?: 0) <= messageIds.size,
+ "조회된 메시지 수는 요청한 messageIds 수 이하여야 함"
+ )
+ println("특정 messageIds 조회 성공 - 요청: ${messageIds.size}건, 조회: ${response.messageList?.size ?: 0}건")
+ }
+
+ @Test
+ fun `메시지 목록 조회 - 복합 필터`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 발신번호 + 타입 + 날짜 범위 조합
+ val request = MessageListRequest(
+ from = senderNumber,
+ type = "SMS",
+ limit = 10
+ )
+ request.setStartDateFromLocalDateTime(LocalDateTime.now().minusDays(30))
+
+ // When
+ val response = messageService!!.getMessageList(request)
+
+ // Then
+ assertNotNull(response)
+ println("복합 필터 조회 성공 - 조회 건수: ${response.messageList?.size ?: 0}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt
new file mode 100644
index 0000000..c2f714b
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/MmsE2ETest.kt
@@ -0,0 +1,161 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.exception.SolapiFileUploadException
+import com.solapi.sdk.message.model.StorageType
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * MMS 발송 E2E 테스트
+ *
+ * MMS는 이미지가 포함된 문자 메시지입니다.
+ * 이미지는 먼저 SOLAPI 서버에 업로드해야 합니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ *
+ * 테스트 리소스 필요:
+ * - src/test/resources/images/test-image.png (MMS 규격에 맞는 이미지)
+ *
+ * MMS 이미지 규격:
+ * - 지원 포맷: JPG, JPEG
+ * - 최대 용량: 200KB
+ * - 권장 해상도: 1000x1000 이하
+ */
+class MmsE2ETest : BaseE2ETest() {
+
+ /**
+ * MMS용 이미지 업로드
+ * MMS는 특정 이미지 규격(JPG, 200KB 이하)을 요구할 수 있습니다.
+ * 업로드 실패 시 null을 반환합니다.
+ */
+ private fun uploadMmsImage(filename: String = "test-image.png"): String? {
+ val imageUrl = javaClass.classLoader.getResource("images/$filename")
+ if (imageUrl == null) {
+ println("테스트 이미지가 없어 건너뜁니다: images/$filename")
+ return null
+ }
+ val file = File(imageUrl.toURI())
+ return try {
+ messageService?.uploadFile(file, StorageType.MMS)
+ } catch (e: SolapiFileUploadException) {
+ println("MMS 이미지 업로드 실패 (서버 응답): ${e.message}")
+ println(" MMS 이미지 규격을 확인하세요 (JPG 포맷, 200KB 이하)")
+ null
+ }
+ }
+
+ @Test
+ fun `MMS 이미지 업로드`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val imageUrl = javaClass.classLoader.getResource("images/test-image.png")
+ if (imageUrl == null) {
+ println("테스트 이미지가 없어 건너뜁니다: images/test-image.png")
+ return
+ }
+ val file = File(imageUrl.toURI())
+
+ // When
+ val imageId = try {
+ messageService!!.uploadFile(file, StorageType.MMS)
+ } catch (e: SolapiFileUploadException) {
+ println("MMS 이미지 업로드 실패 (서버 응답): ${e.message}")
+ println(" MMS 이미지 규격을 확인하세요 (JPG 포맷, 200KB 이하)")
+ println(" 테스트를 건너뜁니다.")
+ return
+ }
+
+ // Then
+ assertNotNull(imageId)
+ println("MMS 이미지 업로드 성공 - imageId: $imageId")
+ }
+
+ @Test
+ fun `MMS 단건 발송`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 이미지 업로드
+ val imageId = uploadMmsImage()
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val message = E2ETestUtils.createMmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] MMS 메시지입니다.",
+ imageId = imageId,
+ subject = "MMS 제목"
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("MMS 단건 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `MMS 발송 - 유효하지 않은 imageId`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 존재하지 않는 imageId
+ val message = E2ETestUtils.createMmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 잘못된 imageId 테스트",
+ imageId = "invalid-image-id-12345"
+ )
+
+ // When & Then
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "유효하지 않은 imageId로 MMS 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `MMS 발송 - 제목과 본문 포함`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val imageId = uploadMmsImage()
+ if (imageId == null) {
+ println("이미지 업로드 실패로 테스트 건너뜀")
+ return
+ }
+
+ val message = E2ETestUtils.createMmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] MMS 본문입니다. 이미지와 함께 발송됩니다.",
+ imageId = imageId,
+ subject = "[SDK 테스트] MMS 제목"
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("MMS 제목/본문 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt
new file mode 100644
index 0000000..d9cc788
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/ScheduledMessageE2ETest.kt
@@ -0,0 +1,268 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.dto.request.SendRequestConfig
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.model.Message
+import java.time.LocalDateTime
+import java.time.ZoneId
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import kotlin.time.Instant
+
+/**
+ * 예약 발송 E2E 테스트
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class ScheduledMessageE2ETest : BaseE2ETest() {
+
+ // ==================== LocalDateTime 예약 발송 테스트 ====================
+
+ @Test
+ fun `예약 발송 - LocalDateTime 시스템 기본 타임존 사용`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] LocalDateTime 예약 발송 테스트 (시스템 기본 타임존)"
+ )
+
+ val scheduledTime = LocalDateTime.now().plusMinutes(10)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(scheduledTime)
+
+ println("예약 시간 (LocalDateTime): $scheduledTime")
+ println("예약 시간 (Instant): ${config.scheduledDate}")
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("예약 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" scheduledDate: ${response.groupInfo?.scheduledDate}")
+ }
+
+ @Test
+ fun `예약 발송 - LocalDateTime 명시적 타임존 사용 (Asia Seoul)`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] LocalDateTime 예약 발송 테스트 (Asia/Seoul)"
+ )
+
+ val seoulZone = ZoneId.of("Asia/Seoul")
+ val scheduledTime = LocalDateTime.now(seoulZone).plusMinutes(15)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(scheduledTime, seoulZone)
+
+ println("예약 시간 (LocalDateTime, Seoul): $scheduledTime")
+ println("예약 시간 (Instant/UTC): ${config.scheduledDate}")
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("예약 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" scheduledDate: ${response.groupInfo?.scheduledDate}")
+ }
+
+ @Test
+ fun `예약 발송 - 기존 Instant API 하위호환성 확인`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] Instant 예약 발송 테스트 (하위호환성)"
+ )
+
+ val scheduledInstant = Instant.fromEpochMilliseconds(
+ System.currentTimeMillis() + 20 * 60 * 1000 // 20분 후
+ )
+ val config = SendRequestConfig(scheduledDate = scheduledInstant)
+
+ println("예약 시간 (Instant): $scheduledInstant")
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("예약 발송 성공 (Instant API) - groupId: ${response.groupInfo?.groupId}")
+ println(" scheduledDate: ${response.groupInfo?.scheduledDate}")
+ }
+
+ @Test
+ fun `예약 발송 - 다중 메시지 LocalDateTime 사용`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val messages = listOf(
+ Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 다중 메시지 예약 발송 1/2"
+ ),
+ Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 다중 메시지 예약 발송 2/2"
+ )
+ )
+
+ val scheduledTime = LocalDateTime.now().plusMinutes(25)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(scheduledTime)
+
+ println("예약 시간: $scheduledTime")
+ println("메시지 수: ${messages.size}")
+
+ // When
+ val response = messageService!!.send(messages, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("다중 메시지 예약 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" count: ${response.groupInfo?.count}")
+ println(" scheduledDate: ${response.groupInfo?.scheduledDate}")
+ }
+
+ // ==================== 특수 케이스 테스트 ====================
+
+ @Test
+ fun `예약 발송 - 과거 시간 지정시 즉시 발송 처리`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 과거 시간 예약 발송 (즉시 발송 예상)"
+ )
+
+ val pastTime = LocalDateTime.now().minusMinutes(10)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(pastTime)
+
+ println("예약 시간 (과거): $pastTime")
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then - 과거 시간은 에러 없이 즉시 발송 처리됨
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("과거 시간 예약 → 즉시 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `예약 발송 - 6개월 이내 미래 시간은 성공`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 5개월 후 예약 발송"
+ )
+
+ val fiveMonthsLater = LocalDateTime.now().plusMonths(5)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(fiveMonthsLater)
+
+ println("예약 시간 (5개월 후): $fiveMonthsLater")
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then - 6개월 이내는 성공
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("5개월 후 예약 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ println(" scheduledDate: ${response.groupInfo?.scheduledDate}")
+ }
+
+ @Test
+ fun `예약 발송 - 6개월 초과 미래 시간 지정시 에러`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 7개월 후 예약 발송 (에러 예상)"
+ )
+
+ val sevenMonthsLater = LocalDateTime.now().plusMonths(7)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(sevenMonthsLater)
+
+ println("예약 시간 (7개월 후): $sevenMonthsLater")
+
+ // When & Then
+ var errorOccurred = false
+ try {
+ messageService!!.send(message, config)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "6개월 초과 예약 발송 시 에러가 발생해야 함")
+ }
+
+ // ==================== 나노초 정밀도 테스트 ====================
+
+ @Test
+ fun `예약 발송 - 나노초 정밀도 유지 확인`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 나노초 정밀도 테스트"
+ )
+
+ // 나노초가 포함된 시간
+ val scheduledTime = LocalDateTime.now().plusMinutes(30).withNano(123456789)
+ val config = SendRequestConfig()
+ config.setScheduledDateFromLocalDateTime(scheduledTime)
+
+ println("예약 시간 (나노초 포함): $scheduledTime")
+ println("변환된 Instant: ${config.scheduledDate}")
+
+ // 나노초가 보존되었는지 확인
+ val instantString = config.scheduledDate.toString()
+ println("Instant 문자열: $instantString")
+ assertTrue(
+ instantString.contains(".123456789Z") || instantString.contains(".123456789"),
+ "나노초 정밀도가 유지되어야 함"
+ )
+
+ // When
+ val response = messageService!!.send(message, config)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("나노초 정밀도 테스트 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt
new file mode 100644
index 0000000..1a7d661
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/SmsE2ETest.kt
@@ -0,0 +1,132 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.model.Message
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+/**
+ * SMS 발송 E2E 테스트
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class SmsE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `SMS 단건 발송 - 기본`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = E2ETestUtils.createSmsMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "[SDK 테스트] SMS 단건 발송 테스트입니다."
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("SMS 단건 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `SMS 단건 발송 - 대시 포함 전화번호`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 대시가 포함된 전화번호 (Message init에서 자동 제거)
+ val message = Message(
+ from = senderNumber.chunked(3).joinToString("-").let {
+ if (senderNumber.length == 11) "${senderNumber.substring(0, 3)}-${senderNumber.substring(3, 7)}-${senderNumber.substring(7)}"
+ else senderNumber
+ },
+ to = testPhoneNumber.let {
+ if (it.length == 11) "${it.substring(0, 3)}-${it.substring(3, 7)}-${it.substring(7)}"
+ else it
+ },
+ text = "[SDK 테스트] 대시 포함 전화번호 테스트입니다."
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("SMS 발송 (대시 포함 전화번호) 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `SMS 배치 발송 - 다중 수신자`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 동일 수신자에게 여러 메시지 (테스트 환경에서는 동일 번호 사용)
+ val messages = E2ETestUtils.createBatchSmsMessages(
+ from = senderNumber,
+ toList = listOf(testPhoneNumber, testPhoneNumber),
+ textPrefix = "[SDK 테스트] 배치 SMS"
+ )
+
+ // When
+ val response = messageService!!.send(messages)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("SMS 배치 발송 성공 - groupId: ${response.groupInfo?.groupId}, count: ${response.groupInfo?.count}")
+ }
+
+ @Test
+ fun `SMS 발송 - 잘못된 발신번호`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 등록되지 않은 발신번호
+ val message = E2ETestUtils.createSmsMessage(
+ from = "00000000000",
+ to = testPhoneNumber,
+ text = "[SDK 테스트] 잘못된 발신번호 테스트"
+ )
+
+ // When & Then
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "등록되지 않은 발신번호로 발송 시 에러가 발생해야 함")
+ }
+
+ @Test
+ fun `SMS 발송 - 빈 메시지`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 빈 텍스트
+ val message = Message(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = ""
+ )
+
+ // When & Then
+ var errorOccurred = false
+ try {
+ messageService!!.send(message)
+ } catch (e: Exception) {
+ errorOccurred = true
+ printExceptionDetails(e)
+ }
+
+ assertTrue(errorOccurred, "빈 메시지 발송 시 에러가 발생해야 함")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt
new file mode 100644
index 0000000..72eb23e
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/VoiceE2ETest.kt
@@ -0,0 +1,163 @@
+package com.solapi.sdk.message.e2e
+
+import com.solapi.sdk.message.e2e.base.BaseE2ETest
+import com.solapi.sdk.message.e2e.lib.E2ETestUtils
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.voice.VoiceOption
+import com.solapi.sdk.message.model.voice.VoiceType
+import kotlin.test.Test
+import kotlin.test.assertNotNull
+
+/**
+ * 음성 메시지 (Voice) E2E 테스트
+ *
+ * 음성 메시지는 TTS(Text-to-Speech)를 통해 텍스트를 음성으로 변환하여 발송합니다.
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ */
+class VoiceE2ETest : BaseE2ETest() {
+
+ @Test
+ fun `음성 메시지 발송 - 여성 음성`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = E2ETestUtils.createVoiceMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "안녕하세요. 테스트 음성 메시지입니다. 여성 음성으로 발송됩니다.",
+ voiceType = VoiceType.FEMALE
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 여성 음성 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `음성 메시지 발송 - 남성 음성`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given
+ val message = E2ETestUtils.createVoiceMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "안녕하세요. 테스트 음성 메시지입니다. 남성 음성으로 발송됩니다.",
+ voiceType = VoiceType.MALE
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 남성 음성 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `음성 메시지 발송 - 헤더 메시지`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 헤더 메시지 포함
+ val message = E2ETestUtils.createVoiceMessage(
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "본문 메시지입니다.",
+ voiceType = VoiceType.FEMALE,
+ headerMessage = "안녕하세요. 솔라피 테스트입니다."
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 헤더 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `음성 메시지 발송 - 테일 메시지`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 테일 메시지 포함
+ // Note: tailMessage는 headerMessage와 함께 사용해야 할 수 있음
+ val message = Message(
+ type = MessageType.VOICE,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "본문 메시지입니다.",
+ voiceOptions = VoiceOption(
+ voiceType = VoiceType.FEMALE,
+ headerMessage = "안녕하세요.",
+ tailMessage = "감사합니다. 좋은 하루 되세요."
+ )
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 테일 포함 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `음성 메시지 발송 - 전체 옵션`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - 모든 옵션 조합
+ val message = Message(
+ type = MessageType.VOICE,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "중요한 공지사항을 안내드립니다.",
+ voiceOptions = VoiceOption(
+ voiceType = VoiceType.FEMALE,
+ headerMessage = "안녕하세요. 솔라피 테스트입니다.",
+ tailMessage = "다시 한번 안내드립니다."
+ )
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 전체 옵션 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+
+ @Test
+ fun `음성 메시지 발송 - 기본값 (여성 음성)`() {
+ if (!assumeBasicEnvironmentConfigured()) return
+
+ // Given - VoiceOption 기본값 사용 (FEMALE)
+ val message = Message(
+ type = MessageType.VOICE,
+ from = senderNumber,
+ to = testPhoneNumber,
+ text = "기본값 테스트 음성 메시지입니다.",
+ voiceOptions = VoiceOption()
+ )
+
+ // When
+ val response = messageService!!.send(message)
+
+ // Then
+ assertNotNull(response)
+ assertNotNull(response.groupInfo?.groupId)
+ println("음성 메시지 기본값 발송 성공 - groupId: ${response.groupInfo?.groupId}")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt
new file mode 100644
index 0000000..309172c
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/base/BaseE2ETest.kt
@@ -0,0 +1,105 @@
+package com.solapi.sdk.message.e2e.base
+
+import com.solapi.sdk.SolapiClient
+import com.solapi.sdk.message.exception.SolapiMessageNotReceivedException
+import com.solapi.sdk.message.model.StorageType
+import com.solapi.sdk.message.service.DefaultMessageService
+import java.io.File
+
+/**
+ * E2E 테스트를 위한 공통 베이스 클래스
+ *
+ * 환경변수 설정 필요:
+ * - SOLAPI_API_KEY: SOLAPI API 키
+ * - SOLAPI_API_SECRET: SOLAPI API 시크릿
+ * - SOLAPI_SENDER: 등록된 발신번호
+ * - SOLAPI_RECIPIENT: 테스트 수신번호
+ *
+ * 카카오 테스트 추가 환경변수:
+ * - SOLAPI_KAKAO_PF_ID: 카카오 비즈니스 채널 ID
+ * - SOLAPI_KAKAO_TEMPLATE_ID: 카카오 알림톡 템플릿 ID (선택)
+ */
+abstract class BaseE2ETest {
+
+ protected val apiKey: String? = System.getenv("SOLAPI_API_KEY")
+ protected val apiSecret: String? = System.getenv("SOLAPI_API_SECRET")
+ protected val senderNumber: String = System.getenv("SOLAPI_SENDER") ?: "01000000000"
+ protected val testPhoneNumber: String = System.getenv("SOLAPI_RECIPIENT") ?: "01000000000"
+ protected val pfId: String? = System.getenv("SOLAPI_KAKAO_PF_ID")
+ protected val templateId: String? = System.getenv("SOLAPI_KAKAO_TEMPLATE_ID")
+
+ protected val messageService: DefaultMessageService? by lazy {
+ if (apiKey != null && apiSecret != null) {
+ SolapiClient.createInstance(apiKey, apiSecret)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * 기본 환경변수 설정 여부 확인 (API Key, Secret)
+ * @return 환경변수가 설정되었으면 true, 아니면 false
+ */
+ protected fun assumeBasicEnvironmentConfigured(): Boolean {
+ if (apiKey.isNullOrBlank() || apiSecret.isNullOrBlank()) {
+ println("환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_API_KEY, SOLAPI_API_SECRET 필요)")
+ return false
+ }
+ return true
+ }
+
+ /**
+ * 카카오 환경변수 설정 여부 확인 (pfId 포함)
+ * @return 환경변수가 설정되었으면 true, 아니면 false
+ */
+ protected fun assumeKakaoEnvironmentConfigured(): Boolean {
+ if (!assumeBasicEnvironmentConfigured()) return false
+ if (pfId.isNullOrBlank()) {
+ println("카카오 환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_KAKAO_PF_ID 필요)")
+ return false
+ }
+ return true
+ }
+
+ /**
+ * 카카오 알림톡 템플릿 환경변수 설정 여부 확인
+ * @return 환경변수가 설정되었으면 true, 아니면 false
+ */
+ protected fun assumeKakaoTemplateConfigured(): Boolean {
+ if (!assumeKakaoEnvironmentConfigured()) return false
+ if (templateId.isNullOrBlank()) {
+ println("카카오 템플릿 환경변수가 설정되지 않아 테스트를 건너뜁니다. (SOLAPI_KAKAO_TEMPLATE_ID 필요)")
+ return false
+ }
+ return true
+ }
+
+ /**
+ * 예외 상세 정보 출력
+ */
+ protected fun printExceptionDetails(e: Exception) {
+ println("예상된 에러 발생: ${e.message}")
+ if (e is SolapiMessageNotReceivedException) {
+ println(" 실패한 메시지 목록 (${e.failedMessageList.size}건):")
+ e.failedMessageList.forEachIndexed { index, failed ->
+ println(" [${index + 1}] to: ${failed.to}, statusCode: ${failed.statusCode}, statusMessage: ${failed.statusMessage}")
+ }
+ }
+ }
+
+ /**
+ * 이미지 파일 업로드
+ * @param storageType 스토리지 타입
+ * @param filename 리소스 파일명 (images/ 디렉토리 내)
+ * @return 업로드된 이미지 ID, 파일이 없으면 null
+ */
+ protected fun uploadImage(storageType: StorageType, filename: String): String? {
+ val imageUrl = javaClass.classLoader.getResource("images/$filename")
+ if (imageUrl == null) {
+ println("테스트 이미지가 없어 건너뜁니다: images/$filename")
+ return null
+ }
+ val file = File(imageUrl.toURI())
+ return messageService?.uploadFile(file, storageType)
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt
new file mode 100644
index 0000000..5ee8541
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/e2e/lib/E2ETestUtils.kt
@@ -0,0 +1,296 @@
+package com.solapi.sdk.message.e2e.lib
+
+import com.solapi.sdk.message.model.Message
+import com.solapi.sdk.message.model.MessageType
+import com.solapi.sdk.message.model.kakao.KakaoButton
+import com.solapi.sdk.message.model.kakao.KakaoButtonType
+import com.solapi.sdk.message.model.kakao.KakaoOption
+import com.solapi.sdk.message.model.voice.VoiceOption
+import com.solapi.sdk.message.model.voice.VoiceType
+
+/**
+ * E2E 테스트를 위한 메시지 팩토리 및 유틸리티 함수들
+ */
+object E2ETestUtils {
+
+ // ==================== SMS/LMS/MMS 메시지 팩토리 ====================
+
+ /**
+ * SMS 메시지 생성
+ */
+ fun createSmsMessage(
+ from: String,
+ to: String,
+ text: String = "[SDK 테스트] SMS 메시지입니다."
+ ): Message = Message(
+ from = from,
+ to = to,
+ text = text
+ )
+
+ /**
+ * LMS 메시지 생성 (명시적 타입 지정)
+ */
+ fun createLmsMessage(
+ from: String,
+ to: String,
+ text: String = generateLongText(100),
+ subject: String? = null
+ ): Message = Message(
+ type = MessageType.LMS,
+ from = from,
+ to = to,
+ text = text,
+ subject = subject
+ )
+
+ /**
+ * MMS 메시지 생성
+ */
+ fun createMmsMessage(
+ from: String,
+ to: String,
+ text: String = "[SDK 테스트] MMS 메시지입니다.",
+ imageId: String,
+ subject: String? = null
+ ): Message = Message(
+ type = MessageType.MMS,
+ from = from,
+ to = to,
+ text = text,
+ imageId = imageId,
+ subject = subject
+ )
+
+ // ==================== 카카오 메시지 팩토리 ====================
+
+ /**
+ * 알림톡 메시지 생성
+ */
+ fun createAlimtalkMessage(
+ from: String,
+ to: String,
+ pfId: String,
+ templateId: String,
+ variables: Map? = null
+ ): Message = Message(
+ type = MessageType.ATA,
+ from = from,
+ to = to,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ templateId = templateId,
+ variables = variables
+ )
+ )
+
+ /**
+ * 알림톡 메시지 생성 (대체 발송 비활성화)
+ */
+ fun createAlimtalkMessageWithoutFallback(
+ from: String,
+ to: String,
+ pfId: String,
+ templateId: String,
+ variables: Map? = null
+ ): Message = Message(
+ type = MessageType.ATA,
+ from = from,
+ to = to,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ templateId = templateId,
+ variables = variables,
+ disableSms = true
+ )
+ )
+
+ /**
+ * 친구톡 메시지 생성 (텍스트만)
+ */
+ fun createFriendTalkMessage(
+ from: String,
+ to: String,
+ text: String = "[SDK 테스트] 친구톡 메시지입니다.",
+ pfId: String,
+ buttons: List? = null,
+ adFlag: Boolean = false
+ ): Message = Message(
+ type = MessageType.CTA,
+ from = from,
+ to = to,
+ text = text,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ buttons = buttons,
+ adFlag = adFlag
+ )
+ )
+
+ /**
+ * 친구톡 이미지 메시지 생성
+ */
+ fun createFriendTalkImageMessage(
+ from: String,
+ to: String,
+ text: String = "[SDK 테스트] 친구톡 이미지 메시지입니다.",
+ pfId: String,
+ imageId: String,
+ buttons: List? = null,
+ adFlag: Boolean = false
+ ): Message = Message(
+ type = MessageType.CTI,
+ from = from,
+ to = to,
+ text = text,
+ kakaoOptions = KakaoOption(
+ pfId = pfId,
+ imageId = imageId,
+ buttons = buttons,
+ adFlag = adFlag
+ )
+ )
+
+ // ==================== 음성 메시지 팩토리 ====================
+
+ /**
+ * 음성 메시지 생성
+ */
+ fun createVoiceMessage(
+ from: String,
+ to: String,
+ text: String = "안녕하세요. 테스트 음성 메시지입니다.",
+ voiceType: VoiceType = VoiceType.FEMALE,
+ headerMessage: String? = null,
+ tailMessage: String? = null
+ ): Message = Message(
+ type = MessageType.VOICE,
+ from = from,
+ to = to,
+ text = text,
+ voiceOptions = VoiceOption(
+ voiceType = voiceType,
+ headerMessage = headerMessage,
+ tailMessage = tailMessage
+ )
+ )
+
+ // ==================== 카카오 버튼 팩토리 ====================
+
+ /**
+ * 웹링크 버튼 생성
+ */
+ fun createWebLinkButton(
+ buttonName: String = "바로가기",
+ linkMo: String = "https://example.com",
+ linkPc: String? = null
+ ): KakaoButton = KakaoButton(
+ buttonName = buttonName,
+ buttonType = KakaoButtonType.WL,
+ linkMo = linkMo,
+ linkPc = linkPc
+ )
+
+ /**
+ * 앱링크 버튼 생성
+ */
+ fun createAppLinkButton(
+ buttonName: String = "앱 열기",
+ linkAnd: String = "intent://main",
+ linkIos: String = "iosapp://main"
+ ): KakaoButton = KakaoButton(
+ buttonName = buttonName,
+ buttonType = KakaoButtonType.AL,
+ linkAnd = linkAnd,
+ linkIos = linkIos
+ )
+
+ /**
+ * 봇키워드 버튼 생성
+ */
+ fun createBotKeywordButton(
+ buttonName: String = "문의하기"
+ ): KakaoButton = KakaoButton(
+ buttonName = buttonName,
+ buttonType = KakaoButtonType.BK
+ )
+
+ /**
+ * 메시지전달 버튼 생성
+ */
+ fun createMessageDeliveryButton(
+ buttonName: String = "전달하기"
+ ): KakaoButton = KakaoButton(
+ buttonName = buttonName,
+ buttonType = KakaoButtonType.MD
+ )
+
+ /**
+ * 채널 추가 버튼 생성
+ */
+ fun createChannelAddButton(
+ buttonName: String = "채널 추가"
+ ): KakaoButton = KakaoButton(
+ buttonName = buttonName,
+ buttonType = KakaoButtonType.AC
+ )
+
+ // ==================== Custom Fields 메시지 팩토리 ====================
+
+ /**
+ * Custom Fields 포함 메시지 생성
+ */
+ fun createMessageWithCustomFields(
+ from: String,
+ to: String,
+ text: String = "[SDK 테스트] Custom Fields 테스트",
+ customFields: MutableMap
+ ): Message = Message(
+ from = from,
+ to = to,
+ text = text,
+ customFields = customFields
+ )
+
+ // ==================== 배치 메시지 팩토리 ====================
+
+ /**
+ * 배치 SMS 메시지 생성
+ */
+ fun createBatchSmsMessages(
+ from: String,
+ toList: List,
+ textPrefix: String = "[SDK 테스트] 배치 SMS"
+ ): List = toList.mapIndexed { index, to ->
+ Message(
+ from = from,
+ to = to,
+ text = "$textPrefix ${index + 1}/${toList.size}"
+ )
+ }
+
+ // ==================== 유틸리티 함수 ====================
+
+ /**
+ * LMS 테스트용 긴 텍스트 생성
+ * @param byteLength 목표 바이트 길이 (한글 기준 약 2배 문자 수)
+ */
+ fun generateLongText(byteLength: Int = 100): String {
+ val prefix = "[SDK 테스트] LMS 긴 메시지 테스트입니다. "
+ val filler = "가나다라마바사아자차카타파하"
+ val sb = StringBuilder(prefix)
+
+ while (sb.toString().toByteArray(Charsets.UTF_8).size < byteLength) {
+ sb.append(filler)
+ }
+
+ return sb.toString()
+ }
+
+ /**
+ * 최대 길이 LMS 메시지 생성 (약 2000바이트)
+ */
+ fun generateMaxLengthLmsText(): String {
+ return generateLongText(1900) // 약간의 여유를 두고 1900바이트
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt
new file mode 100644
index 0000000..848c99e
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/lib/AuthenticatorTest.kt
@@ -0,0 +1,136 @@
+package com.solapi.sdk.message.lib
+
+import com.solapi.sdk.message.exception.SolapiApiKeyException
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+class AuthenticatorTest {
+
+ @Test
+ fun `generateAuthInfo returns HMAC-SHA256 format`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "test-api-secret")
+
+ // When
+ val authInfo = authenticator.generateAuthInfo()
+
+ // Then
+ assertTrue(authInfo.startsWith("HMAC-SHA256 "))
+ assertTrue(authInfo.contains("Apikey=test-api-key"))
+ assertTrue(authInfo.contains("Date="))
+ assertTrue(authInfo.contains("salt="))
+ assertTrue(authInfo.contains("signature="))
+ }
+
+ @Test
+ fun `generateAuthInfo includes UTC timestamp`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "test-api-secret")
+
+ // When
+ val authInfo = authenticator.generateAuthInfo()
+
+ // Then - UTC ISO-8601 형식의 타임스탬프가 포함되어야 함
+ val dateMatch = Regex("Date=([^,]+)").find(authInfo)
+ assertNotNull(dateMatch)
+
+ val dateValue = dateMatch.groupValues[1]
+ // kotlin.time.Instant.toString() 형식: "2024-01-15T10:30:45.123456789Z"
+ assertTrue(dateValue.endsWith("Z"), "Timestamp should be in UTC format (ends with Z)")
+ assertTrue(dateValue.contains("T"), "Timestamp should contain 'T' separator")
+ }
+
+ @Test
+ fun `generateAuthInfo includes 32-character salt`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "test-api-secret")
+
+ // When
+ val authInfo = authenticator.generateAuthInfo()
+
+ // Then
+ val saltMatch = Regex("salt=([a-f0-9]+)").find(authInfo)
+ assertNotNull(saltMatch)
+ assertEquals(32, saltMatch.groupValues[1].length, "Salt should be 32 hex characters (UUID without dashes)")
+ }
+
+ @Test
+ fun `generateAuthInfo includes 64-character signature`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "test-api-secret")
+
+ // When
+ val authInfo = authenticator.generateAuthInfo()
+
+ // Then - HMAC-SHA256 produces 64 hex characters
+ val signatureMatch = Regex("signature=([a-f0-9]+)").find(authInfo)
+ assertNotNull(signatureMatch)
+ assertEquals(64, signatureMatch.groupValues[1].length, "Signature should be 64 hex characters (SHA-256)")
+ }
+
+ @Test
+ fun `generateAuthInfo throws exception for empty apiKey`() {
+ // Given
+ val authenticator = Authenticator("", "test-api-secret")
+
+ // When & Then
+ assertFailsWith {
+ authenticator.generateAuthInfo()
+ }
+ }
+
+ @Test
+ fun `generateAuthInfo throws exception for empty apiSecretKey`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "")
+
+ // When & Then
+ assertFailsWith {
+ authenticator.generateAuthInfo()
+ }
+ }
+
+ @Test
+ fun `generateAuthInfo throws exception for both empty keys`() {
+ // Given
+ val authenticator = Authenticator("", "")
+
+ // When & Then
+ assertFailsWith {
+ authenticator.generateAuthInfo()
+ }
+ }
+
+ @Test
+ fun `generateAuthInfo produces different signatures for different calls`() {
+ // Given
+ val authenticator = Authenticator("test-api-key", "test-api-secret")
+
+ // When
+ val authInfo1 = authenticator.generateAuthInfo()
+ val authInfo2 = authenticator.generateAuthInfo()
+
+ // Then - salt가 다르므로 signature도 달라야 함
+ val signature1 = Regex("signature=([a-f0-9]+)").find(authInfo1)?.groupValues?.get(1)
+ val signature2 = Regex("signature=([a-f0-9]+)").find(authInfo2)?.groupValues?.get(1)
+
+ assertNotEquals(signature1, signature2, "Each call should produce different signature due to unique salt")
+ }
+
+ @Test
+ fun `generateAuthInfo produces consistent format`() {
+ // Given
+ val authenticator = Authenticator("my-key", "my-secret")
+
+ // When
+ val authInfo = authenticator.generateAuthInfo()
+
+ // Then - 정확한 형식 검증
+ val pattern = Regex("^HMAC-SHA256 Apikey=my-key, Date=[^,]+, salt=[a-f0-9]{32}, signature=[a-f0-9]{64}$")
+ assertTrue(pattern.matches(authInfo), "Auth info format should match expected pattern")
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt
new file mode 100644
index 0000000..3910a65
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/lib/BmsTestUtils.kt
@@ -0,0 +1,534 @@
+package com.solapi.sdk.message.lib
+
+import com.solapi.sdk.message.model.kakao.KakaoBmsOption
+import com.solapi.sdk.message.model.kakao.KakaoBmsTargeting
+import com.solapi.sdk.message.model.kakao.bms.*
+
+/**
+ * BMS Free 테스트 헬퍼 함수들
+ */
+object BmsTestUtils {
+
+ /**
+ * 웹링크 버튼 생성
+ */
+ fun createWebLinkButton(
+ name: String = "버튼",
+ linkMobile: String = "https://example.com",
+ linkPc: String? = null,
+ targetOut: Boolean? = null
+ ): BmsButton = BmsButton(
+ linkType = BmsButtonType.WL,
+ name = name,
+ linkMobile = linkMobile,
+ linkPc = linkPc,
+ targetOut = targetOut
+ )
+
+ /**
+ * 앱링크 버튼 생성
+ */
+ fun createAppLinkButton(
+ name: String = "앱 열기",
+ linkAndroid: String = "intent://...",
+ linkIos: String = "iosapp://..."
+ ): BmsButton = BmsButton(
+ linkType = BmsButtonType.AL,
+ name = name,
+ linkAndroid = linkAndroid,
+ linkIos = linkIos
+ )
+
+ /**
+ * 채널 추가 버튼 생성
+ */
+ fun createChannelAddButton(name: String = "채널 추가"): BmsButton = BmsButton(
+ linkType = BmsButtonType.AC,
+ name = name
+ )
+
+ /**
+ * 봇 키워드 버튼 생성
+ */
+ fun createBotKeywordButton(name: String = "키워드"): BmsButton = BmsButton(
+ linkType = BmsButtonType.BK,
+ name = name
+ )
+
+ /**
+ * 쿠폰 생성 - 퍼센트 할인
+ */
+ fun createPercentCoupon(
+ percent: Int = 10,
+ description: String = "설명",
+ linkMobile: String? = "https://example.com"
+ ): BmsCoupon = BmsCoupon(
+ title = "${percent}% 할인 쿠폰",
+ description = description,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 쿠폰 생성 - 금액 할인
+ */
+ fun createWonCoupon(
+ won: Int = 5000,
+ description: String = "설명",
+ linkMobile: String? = "https://example.com"
+ ): BmsCoupon = BmsCoupon(
+ title = "${won}원 할인 쿠폰",
+ description = description,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 쿠폰 생성 - 배송비 할인
+ */
+ fun createShippingCoupon(
+ description: String = "설명",
+ linkMobile: String? = "https://example.com"
+ ): BmsCoupon = BmsCoupon(
+ title = "배송비 할인 쿠폰",
+ description = description,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 쿠폰 생성 - 무료 쿠폰
+ */
+ fun createFreeCoupon(
+ item: String = "커피",
+ description: String = "설명",
+ linkMobile: String? = "https://example.com"
+ ): BmsCoupon = BmsCoupon(
+ title = "$item 무료 쿠폰",
+ description = description,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 쿠폰 생성 - UP 쿠폰
+ */
+ fun createUpCoupon(
+ item: String = "포인트",
+ description: String = "설명",
+ linkMobile: String? = "https://example.com"
+ ): BmsCoupon = BmsCoupon(
+ title = "$item UP 쿠폰",
+ description = description,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 커머스 정보 생성
+ */
+ fun createCommerce(
+ title: String = "상품명",
+ regularPrice: Long = 50000,
+ discountPrice: Long? = null,
+ discountRate: Int? = null,
+ discountFixed: Long? = null
+ ): BmsCommerce = BmsCommerce(
+ title = title,
+ regularPrice = regularPrice,
+ discountPrice = discountPrice,
+ discountRate = discountRate,
+ discountFixed = discountFixed
+ )
+
+ /**
+ * 비디오 정보 생성
+ */
+ fun createVideo(
+ videoUrl: String,
+ imageId: String,
+ imageLink: String? = null
+ ): BmsVideo = BmsVideo(
+ videoUrl = videoUrl,
+ imageId = imageId,
+ imageLink = imageLink
+ )
+
+ /**
+ * 캐러셀 피드 아이템 생성
+ */
+ fun createCarouselFeedItem(
+ imageId: String,
+ header: String = "제목",
+ content: String = "내용",
+ imageLink: String? = null,
+ buttons: List? = null,
+ coupon: BmsCoupon? = null
+ ): BmsCarouselItem = BmsCarouselItem(
+ header = header,
+ content = content,
+ imageId = imageId,
+ imageLink = imageLink,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * 캐러셀 커머스 아이템 생성
+ */
+ fun createCarouselCommerceItem(
+ imageId: String,
+ commerce: BmsCommerce,
+ additionalContent: String? = null,
+ imageLink: String? = null,
+ buttons: List? = null,
+ coupon: BmsCoupon? = null
+ ): BmsCarouselItem = BmsCarouselItem(
+ commerce = commerce,
+ additionalContent = additionalContent,
+ imageId = imageId,
+ imageLink = imageLink,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * 캐러셀 헤드 생성
+ */
+ fun createCarouselHead(
+ header: String = "인트로",
+ content: String? = null,
+ imageId: String? = null,
+ linkMobile: String? = null
+ ): BmsCarouselHead = BmsCarouselHead(
+ header = header,
+ content = content,
+ imageId = imageId,
+ linkMobile = linkMobile
+ )
+
+ /**
+ * 캐러셀 테일 생성
+ */
+ fun createCarouselTail(
+ linkMobile: String = "https://example.com",
+ linkPc: String? = null
+ ): BmsCarouselTail = BmsCarouselTail(
+ linkMobile = linkMobile,
+ linkPc = linkPc
+ )
+
+ /**
+ * 메인 와이드 아이템 생성
+ */
+ fun createMainWideItem(
+ imageId: String,
+ title: String = "메인 타이틀",
+ linkMobile: String? = "https://example.com",
+ linkPc: String? = null
+ ): BmsMainWideItem = BmsMainWideItem(
+ title = title,
+ imageId = imageId,
+ linkMobile = linkMobile,
+ linkPc = linkPc
+ )
+
+ /**
+ * 서브 와이드 아이템 생성
+ */
+ fun createSubWideItem(
+ imageId: String,
+ title: String,
+ linkMobile: String? = "https://example.com",
+ linkPc: String? = null
+ ): BmsSubWideItem = BmsSubWideItem(
+ title = title,
+ imageId = imageId,
+ linkMobile = linkMobile,
+ linkPc = linkPc
+ )
+
+ /**
+ * TEXT 타입 BMS 옵션 생성 (최소)
+ */
+ fun createTextBmsOption(
+ content: String = "텍스트 메시지 내용",
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.TEXT,
+ content = content
+ )
+
+ /**
+ * TEXT 타입 BMS 옵션 생성 (전체 필드)
+ * TEXT 타입은 adult, content, buttons, coupon만 지원 (header, additionalContent 미지원)
+ */
+ fun createTextBmsOptionFull(
+ content: String = "텍스트 메시지 내용",
+ buttons: List,
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.TEXT,
+ adult = adult,
+ content = content,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * IMAGE 타입 BMS 옵션 생성 (최소)
+ */
+ fun createImageBmsOption(
+ imageId: String,
+ content: String = "이미지 메시지 내용",
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.IMAGE,
+ imageId = imageId,
+ content = content
+ )
+
+ /**
+ * IMAGE 타입 BMS 옵션 생성 (전체 필드)
+ * IMAGE 타입은 header, additionalContent 미지원 - Message.text로 content 전달
+ */
+ fun createImageBmsOptionFull(
+ imageId: String,
+ imageLink: String = "https://example.com",
+ buttons: List,
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.IMAGE,
+ adult = adult,
+ imageId = imageId,
+ imageLink = imageLink,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * WIDE 타입 BMS 옵션 생성 (최소)
+ */
+ fun createWideBmsOption(
+ imageId: String,
+ content: String = "와이드 메시지 내용",
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.WIDE,
+ imageId = imageId,
+ content = content
+ )
+
+ /**
+ * WIDE 타입 BMS 옵션 생성 (전체 필드)
+ */
+ fun createWideBmsOptionFull(
+ imageId: String,
+ imageLink: String = "https://example.com",
+ buttons: List,
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.WIDE,
+ adult = adult,
+ imageId = imageId,
+ imageLink = imageLink,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * WIDE_ITEM_LIST 타입 BMS 옵션 생성 (최소)
+ * header는 WIDE_ITEM_LIST 타입의 필수 필드
+ */
+ fun createWideItemListBmsOption(
+ mainWideItem: BmsMainWideItem,
+ subWideItemList: List,
+ header: String = "WIDE_ITEM_LIST",
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.WIDE_ITEM_LIST,
+ header = header,
+ mainWideItem = mainWideItem,
+ subWideItemList = subWideItemList
+ )
+
+ /**
+ * WIDE_ITEM_LIST 타입 BMS 옵션 생성 (전체 필드)
+ */
+ fun createWideItemListBmsOptionFull(
+ mainWideItem: BmsMainWideItem,
+ subWideItemList: List,
+ header: String = "헤더",
+ buttons: List,
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.WIDE_ITEM_LIST,
+ adult = adult,
+ header = header,
+ mainWideItem = mainWideItem,
+ subWideItemList = subWideItemList,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * COMMERCE 타입 BMS 옵션 생성 (최소)
+ */
+ fun createCommerceBmsOption(
+ imageId: String,
+ commerce: BmsCommerce,
+ buttons: List,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.COMMERCE,
+ imageId = imageId,
+ commerce = commerce,
+ buttons = buttons
+ )
+
+ /**
+ * COMMERCE 타입 BMS 옵션 생성 (전체 필드)
+ * COMMERCE 타입은 header 미지원, additionalContent 지원
+ */
+ fun createCommerceBmsOptionFull(
+ imageId: String,
+ commerce: BmsCommerce,
+ buttons: List,
+ imageLink: String = "https://example.com",
+ additionalContent: String = "추가 내용",
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.COMMERCE,
+ adult = adult,
+ imageId = imageId,
+ imageLink = imageLink,
+ additionalContent = additionalContent,
+ commerce = commerce,
+ buttons = buttons,
+ coupon = coupon
+ )
+
+ /**
+ * CAROUSEL_FEED 타입 BMS 옵션 생성 (최소)
+ */
+ fun createCarouselFeedBmsOption(
+ carouselItems: List,
+ tail: BmsCarouselTail? = null,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED,
+ carousel = BmsCarousel(
+ list = carouselItems,
+ tail = tail
+ )
+ )
+
+ /**
+ * CAROUSEL_FEED 타입 BMS 옵션 생성 (전체 필드)
+ */
+ fun createCarouselFeedBmsOptionFull(
+ carouselItems: List,
+ tail: BmsCarouselTail,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED,
+ adult = adult,
+ carousel = BmsCarousel(
+ list = carouselItems,
+ tail = tail
+ )
+ )
+
+ /**
+ * CAROUSEL_COMMERCE 타입 BMS 옵션 생성 (최소)
+ */
+ fun createCarouselCommerceBmsOption(
+ carouselItems: List,
+ tail: BmsCarouselTail? = null,
+ head: BmsCarouselHead? = null,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE,
+ carousel = BmsCarousel(
+ head = head,
+ list = carouselItems,
+ tail = tail
+ )
+ )
+
+ /**
+ * CAROUSEL_COMMERCE 타입 BMS 옵션 생성 (전체 필드)
+ */
+ fun createCarouselCommerceBmsOptionFull(
+ carouselItems: List,
+ tail: BmsCarouselTail,
+ head: BmsCarouselHead,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE,
+ adult = adult,
+ carousel = BmsCarousel(
+ head = head,
+ list = carouselItems,
+ tail = tail
+ )
+ )
+
+ /**
+ * PREMIUM_VIDEO 타입 BMS 옵션 생성 (최소)
+ */
+ fun createPremiumVideoBmsOption(
+ video: BmsVideo,
+ content: String = "비디오 메시지 내용",
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO,
+ video = video,
+ content = content
+ )
+
+ /**
+ * PREMIUM_VIDEO 타입 BMS 옵션 생성 (전체 필드)
+ * PREMIUM_VIDEO 타입은 adult, header, content, video, buttons, coupon만 지원 (additionalContent 미지원)
+ */
+ fun createPremiumVideoBmsOptionFull(
+ video: BmsVideo,
+ content: String = "비디오 메시지 내용",
+ header: String = "헤더",
+ buttons: List,
+ coupon: BmsCoupon? = null,
+ adult: Boolean = false,
+ targeting: KakaoBmsTargeting = KakaoBmsTargeting.I
+ ): KakaoBmsOption = KakaoBmsOption(
+ targeting = targeting,
+ chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO,
+ adult = adult,
+ header = header,
+ video = video,
+ content = content,
+ buttons = buttons,
+ coupon = coupon
+ )
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt
new file mode 100644
index 0000000..886f8fe
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/lib/JsonSupportTest.kt
@@ -0,0 +1,117 @@
+package com.solapi.sdk.message.lib
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.time.Instant
+import kotlinx.serialization.Contextual
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+
+class JsonSupportTest {
+
+ @Serializable
+ private data class InstantHolder(
+ @Contextual
+ val timestamp: Instant
+ )
+
+ @Serializable
+ private data class NullableInstantHolder(
+ @Contextual
+ val timestamp: Instant? = null
+ )
+
+ @Test
+ fun `Instant serializes to ISO-8601 format`() {
+ // Given
+ val instant = Instant.parse("2024-01-15T10:30:45.123456789Z")
+ val holder = InstantHolder(instant)
+
+ // When
+ val json = JsonSupport.json.encodeToString(holder)
+
+ // Then
+ assertEquals("""{"timestamp":"2024-01-15T10:30:45.123456789Z"}""", json)
+ }
+
+ @Test
+ fun `Instant deserializes from ISO-8601 format`() {
+ // Given
+ val json = """{"timestamp":"2024-01-15T10:30:45.123456789Z"}"""
+
+ // When
+ val holder = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ val expected = Instant.parse("2024-01-15T10:30:45.123456789Z")
+ assertEquals(expected, holder.timestamp)
+ }
+
+ @Test
+ fun `Instant round-trip serialization preserves value`() {
+ // Given
+ val original = InstantHolder(Instant.parse("2024-12-31T23:59:59.999999999Z"))
+
+ // When
+ val json = JsonSupport.json.encodeToString(original)
+ val restored = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(original, restored)
+ }
+
+ @Test
+ fun `null Instant serializes correctly`() {
+ // Given
+ val holder = NullableInstantHolder(timestamp = null)
+
+ // When
+ val json = JsonSupport.json.encodeToString(holder)
+
+ // Then - explicitNulls = false 이므로 null 필드는 생략됨
+ assertEquals("{}", json)
+ }
+
+ @Test
+ fun `missing Instant field deserializes to null`() {
+ // Given
+ val json = "{}"
+
+ // When
+ val holder = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertNull(holder.timestamp)
+ }
+
+ @Test
+ fun `Instant with zero nanoseconds serializes correctly`() {
+ // Given
+ val instant = Instant.parse("2024-01-15T10:30:45Z")
+ val holder = InstantHolder(instant)
+
+ // When
+ val json = JsonSupport.json.encodeToString(holder)
+
+ // Then
+ assertNotNull(json)
+ val restored = JsonSupport.json.decodeFromString(json)
+ assertEquals(instant, restored.timestamp)
+ }
+
+ @Test
+ fun `epoch Instant serializes correctly`() {
+ // Given
+ val epoch = Instant.fromEpochSeconds(0)
+ val holder = InstantHolder(epoch)
+
+ // When
+ val json = JsonSupport.json.encodeToString(holder)
+ val restored = JsonSupport.json.decodeFromString(json)
+
+ // Then
+ assertEquals(epoch, restored.timestamp)
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt b/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt
new file mode 100644
index 0000000..28f4c62
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/lib/LocalDateTimeSupportTest.kt
@@ -0,0 +1,83 @@
+package com.solapi.sdk.message.lib
+
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.time.Instant
+
+class LocalDateTimeSupportTest {
+
+ @Test
+ fun `toKotlinInstant converts LocalDateTime with system default timezone`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+
+ // When
+ val instant = localDateTime.toKotlinInstant()
+
+ // Then
+ val expectedJavaInstant = localDateTime
+ .atZone(ZoneId.systemDefault())
+ .toInstant()
+ val actualJavaInstant = java.time.Instant.parse(instant.toString())
+ assertEquals(expectedJavaInstant, actualJavaInstant)
+ }
+
+ @Test
+ fun `toKotlinInstant converts LocalDateTime with explicit UTC timezone`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+ val utcZone = ZoneOffset.UTC
+
+ // When
+ val instant = localDateTime.toKotlinInstant(utcZone)
+
+ // Then
+ val expectedInstant = Instant.parse("2024-06-15T14:30:00Z")
+ assertEquals(expectedInstant, instant)
+ }
+
+ @Test
+ fun `toKotlinInstant converts LocalDateTime with Asia Seoul timezone`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+ val seoulZone = ZoneId.of("Asia/Seoul")
+
+ // When
+ val instant = localDateTime.toKotlinInstant(seoulZone)
+
+ // Then
+ val expectedInstant = Instant.parse("2024-06-15T05:30:00Z")
+ assertEquals(expectedInstant, instant)
+ }
+
+ @Test
+ fun `LocalDateTimeSupport static method works for Java interop`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0)
+ val utcZone = ZoneOffset.UTC
+
+ // When
+ val instant = LocalDateTimeSupport.toKotlinInstant(localDateTime, utcZone)
+
+ // Then
+ val expectedInstant = Instant.parse("2024-06-15T14:30:00Z")
+ assertEquals(expectedInstant, instant)
+ }
+
+ @Test
+ fun `toKotlinInstant preserves nanosecond precision`() {
+ // Given
+ val localDateTime = LocalDateTime.of(2024, 6, 15, 14, 30, 0, 123456789)
+ val utcZone = ZoneOffset.UTC
+
+ // When
+ val instant = localDateTime.toKotlinInstant(utcZone)
+
+ // Then
+ val expectedInstant = Instant.parse("2024-06-15T14:30:00.123456789Z")
+ assertEquals(expectedInstant, instant)
+ }
+}
diff --git a/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt b/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt
new file mode 100644
index 0000000..2f5c641
--- /dev/null
+++ b/src/test/kotlin/com/solapi/sdk/message/model/kakao/bms/BmsSerializationTest.kt
@@ -0,0 +1,270 @@
+package com.solapi.sdk.message.model.kakao.bms
+
+import com.solapi.sdk.message.lib.JsonSupport
+import com.solapi.sdk.message.model.kakao.KakaoBmsOption
+import com.solapi.sdk.message.model.kakao.KakaoBmsTargeting
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlinx.serialization.encodeToString
+
+class BmsSerializationTest {
+
+ @Test
+ fun `BmsChatBubbleType serializes to uppercase string`() {
+ // Given
+ val chatBubbleType = BmsChatBubbleType.PREMIUM_VIDEO
+
+ // When
+ val json = JsonSupport.json.encodeToString(chatBubbleType)
+
+ // Then
+ assertTrue(json.contains("\"PREMIUM_VIDEO\""))
+ }
+
+ @Test
+ fun `BmsButtonType serializes correctly`() {
+ // Given
+ val buttonTypeWL = BmsButtonType.WL
+ val buttonTypeBF = BmsButtonType.BF
+
+ // When
+ val jsonWL = JsonSupport.json.encodeToString(buttonTypeWL)
+ val jsonBF = JsonSupport.json.encodeToString(buttonTypeBF)
+
+ // Then
+ assertTrue(jsonWL.contains("\"WL\""))
+ assertTrue(jsonBF.contains("\"BF\""))
+ }
+
+ @Test
+ fun `BmsButton serializes all fields correctly`() {
+ // Given
+ val button = BmsButton(
+ linkType = BmsButtonType.WL,
+ name = "버튼",
+ linkMobile = "https://example.com",
+ targetOut = true
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(button)
+
+ // Then
+ assertTrue(json.contains("\"linkType\":\"WL\""))
+ assertTrue(json.contains("\"name\":\"버튼\""))
+ assertTrue(json.contains("\"linkMobile\":\"https://example.com\""))
+ assertTrue(json.contains("\"targetOut\":true"))
+ }
+
+ @Test
+ fun `BmsCommerce serializes prices as numbers without quotes`() {
+ // Given
+ val commerce = BmsCommerce(
+ title = "상품",
+ regularPrice = 129000,
+ discountPrice = 99000,
+ discountRate = 23
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(commerce)
+
+ // Then
+ assertTrue(json.contains("\"regularPrice\":129000"))
+ assertTrue(json.contains("\"discountPrice\":99000"))
+ assertTrue(json.contains("\"discountRate\":23"))
+ assertFalse(json.contains("\"regularPrice\":\"129000\""))
+ }
+
+ @Test
+ fun `BmsCoupon serializes with required fields`() {
+ // Given
+ val coupon = BmsCoupon(
+ title = "5000원 할인 쿠폰",
+ description = "설명",
+ linkMobile = "https://example.com"
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(coupon)
+
+ // Then
+ assertTrue(json.contains("\"title\":\"5000원 할인 쿠폰\""))
+ assertTrue(json.contains("\"description\":\"설명\""))
+ assertTrue(json.contains("\"linkMobile\":\"https://example.com\""))
+ }
+
+ @Test
+ fun `BmsVideo serializes all fields`() {
+ // Given
+ val video = BmsVideo(
+ videoUrl = "https://tv.kakao.com/v/123456",
+ imageId = "IMG001",
+ imageLink = "https://example.com"
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(video)
+
+ // Then
+ assertTrue(json.contains("\"videoUrl\":\"https://tv.kakao.com/v/123456\""))
+ assertTrue(json.contains("\"imageId\":\"IMG001\""))
+ assertTrue(json.contains("\"imageLink\":\"https://example.com\""))
+ }
+
+ @Test
+ fun `BmsMainWideItem serializes without header and content fields`() {
+ // Given
+ val wideItem = BmsMainWideItem(
+ title = "타이틀",
+ imageId = "IMG123",
+ linkMobile = "https://example.com"
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(wideItem)
+
+ // Then
+ assertTrue(json.contains("\"title\":\"타이틀\""))
+ assertTrue(json.contains("\"imageId\":\"IMG123\""))
+ assertTrue(json.contains("\"linkMobile\":\"https://example.com\""))
+ assertFalse(json.contains("\"header\""))
+ assertFalse(json.contains("\"content\""))
+ assertFalse(json.contains("\"buttons\""))
+ }
+
+ @Test
+ fun `BmsCarouselItem with Feed fields serializes correctly`() {
+ // Given
+ val item = BmsCarouselItem(
+ header = "제목",
+ content = "내용",
+ imageId = "IMG123"
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(item)
+
+ // Then
+ assertTrue(json.contains("\"header\":\"제목\""))
+ assertTrue(json.contains("\"content\":\"내용\""))
+ assertTrue(json.contains("\"imageId\":\"IMG123\""))
+ }
+
+ @Test
+ fun `BmsCarouselItem with Commerce fields serializes correctly`() {
+ // Given
+ val commerce = BmsCommerce(title = "상품", regularPrice = 10000)
+ val item = BmsCarouselItem(
+ commerce = commerce,
+ additionalContent = "추가정보",
+ imageId = "IMG123"
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(item)
+
+ // Then
+ assertTrue(json.contains("\"commerce\""))
+ assertTrue(json.contains("\"additionalContent\":\"추가정보\""))
+ assertTrue(json.contains("\"imageId\":\"IMG123\""))
+ }
+
+ @Test
+ fun `KakaoBmsOption maintains backward compatibility with targeting only`() {
+ // Given
+ val option = KakaoBmsOption(targeting = KakaoBmsTargeting.I)
+
+ // When
+ val json = JsonSupport.json.encodeToString(option)
+
+ // Then
+ assertTrue(json.contains("\"targeting\":\"I\""))
+ }
+
+ @Test
+ fun `KakaoBmsOption with CAROUSEL_FEED serializes without head field`() {
+ // Given
+ val option = KakaoBmsOption(
+ targeting = KakaoBmsTargeting.I,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_FEED,
+ carousel = BmsCarousel(
+ list = listOf(
+ BmsCarouselItem(header = "제목", content = "내용", imageId = "IMG123")
+ ),
+ tail = BmsCarouselTail(linkMobile = "https://example.com")
+ )
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(option)
+
+ // Then
+ assertTrue(json.contains("\"chatBubbleType\":\"CAROUSEL_FEED\""))
+ assertTrue(json.contains("\"carousel\""))
+ assertTrue(json.contains("\"list\""))
+ assertTrue(json.contains("\"header\":\"제목\""))
+ assertFalse(json.contains("\"head\""))
+ }
+
+ @Test
+ fun `KakaoBmsOption with CAROUSEL_COMMERCE serializes with head field`() {
+ // Given
+ val option = KakaoBmsOption(
+ targeting = KakaoBmsTargeting.I,
+ chatBubbleType = BmsChatBubbleType.CAROUSEL_COMMERCE,
+ carousel = BmsCarousel(
+ head = BmsCarouselHead(header = "인트로", content = "설명", imageId = "IMG000"),
+ list = listOf(
+ BmsCarouselItem(
+ commerce = BmsCommerce(title = "상품", regularPrice = 129000),
+ imageId = "IMG123"
+ )
+ ),
+ tail = BmsCarouselTail(linkMobile = "https://example.com")
+ )
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(option)
+
+ // Then
+ assertTrue(json.contains("\"chatBubbleType\":\"CAROUSEL_COMMERCE\""))
+ assertTrue(json.contains("\"carousel\""))
+ assertTrue(json.contains("\"head\""))
+ assertTrue(json.contains("\"header\":\"인트로\""))
+ assertTrue(json.contains("\"commerce\""))
+ }
+
+ @Test
+ fun `KakaoBmsOption serializes all field types correctly`() {
+ // Given
+ val option = KakaoBmsOption(
+ targeting = KakaoBmsTargeting.I,
+ chatBubbleType = BmsChatBubbleType.COMMERCE,
+ adult = false,
+ header = "헤더",
+ imageId = "IMG001",
+ imageLink = "https://example.com",
+ additionalContent = "추가내용",
+ content = "본문",
+ buttons = listOf(
+ BmsButton(linkType = BmsButtonType.WL, name = "버튼", linkMobile = "https://example.com")
+ ),
+ commerce = BmsCommerce(title = "상품", regularPrice = 50000),
+ video = BmsVideo(videoUrl = "https://tv.kakao.com/v/123456")
+ )
+
+ // When
+ val json = JsonSupport.json.encodeToString(option)
+
+ // Then
+ assertTrue(json.contains("\"chatBubbleType\":\"COMMERCE\""))
+ assertTrue(json.contains("\"targeting\":\"I\""))
+ assertTrue(json.contains("\"adult\":false"))
+ assertTrue(json.contains("\"buttons\""))
+ assertTrue(json.contains("\"commerce\""))
+ assertTrue(json.contains("\"video\""))
+ }
+}
diff --git a/src/test/resources/images/test-image-1to1.png b/src/test/resources/images/test-image-1to1.png
new file mode 100644
index 0000000..2a4a556
Binary files /dev/null and b/src/test/resources/images/test-image-1to1.png differ
diff --git a/src/test/resources/images/test-image-2to1.png b/src/test/resources/images/test-image-2to1.png
new file mode 100644
index 0000000..aeb8e11
Binary files /dev/null and b/src/test/resources/images/test-image-2to1.png differ
diff --git a/src/test/resources/images/test-image.png b/src/test/resources/images/test-image.png
new file mode 100644
index 0000000..d41a0ef
Binary files /dev/null and b/src/test/resources/images/test-image.png differ