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 -### 실행방법 +[![Maven Central](https://img.shields.io/maven-central/v/com.solapi/sdk)](https://central.sonatype.com/artifact/com.solapi/sdk) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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