mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2026-06-12 23:19:10 +00:00
AI 서비스 Kafka/Redis 통합 테스트 및 설정 개선
- Gradle 빌드 캐시 파일 제외 (.gitignore 업데이트) - Kafka 통합 테스트 구현 (AIJobConsumerIntegrationTest) - 단위 테스트 추가 (Controller, Service 레이어) - IntelliJ 실행 프로파일 자동 생성 도구 추가 - Kafka 테스트 배치 스크립트 추가 - Redis 캐시 설정 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -23,3 +23,11 @@ dependencies {
|
||||
// Note: PostgreSQL dependency is in root build.gradle but AI Service doesn't use DB
|
||||
// We still include it for consistency, but no JPA entities will be created
|
||||
}
|
||||
|
||||
// Kafka Manual Test 실행 태스크
|
||||
task runKafkaManualTest(type: JavaExec) {
|
||||
group = 'verification'
|
||||
description = 'Run Kafka manual test'
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
mainClass = 'com.kt.ai.test.manual.KafkaManualTest'
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.kt.ai;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
|
||||
import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
|
||||
/**
|
||||
@@ -14,7 +15,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients;
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@EnableFeignClients
|
||||
@SpringBootApplication
|
||||
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
|
||||
public class AiServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -30,11 +30,10 @@ public interface ClaudeApiClient {
|
||||
* @param request Claude 요청
|
||||
* @return Claude 응답
|
||||
*/
|
||||
@PostMapping
|
||||
@PostMapping(consumes = "application/json", produces = "application/json")
|
||||
ClaudeResponse sendMessage(
|
||||
@RequestHeader("x-api-key") String apiKey,
|
||||
@RequestHeader("anthropic-version") String anthropicVersion,
|
||||
@RequestHeader("content-type") String contentType,
|
||||
@RequestBody ClaudeRequest request
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package com.kt.ai.config;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.SocketOptions;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
|
||||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Redis 설정
|
||||
* - 작업 상태 및 추천 결과 캐싱
|
||||
@@ -33,6 +41,9 @@ public class RedisConfig {
|
||||
@Value("${spring.data.redis.database}")
|
||||
private int redisDatabase;
|
||||
|
||||
@Value("${spring.data.redis.timeout:3000}")
|
||||
private long redisTimeout;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리 설정
|
||||
*/
|
||||
@@ -46,13 +57,46 @@ public class RedisConfig {
|
||||
}
|
||||
config.setDatabase(redisDatabase);
|
||||
|
||||
return new LettuceConnectionFactory(config);
|
||||
// Lettuce Client 설정: Timeout 및 Connection 옵션
|
||||
SocketOptions socketOptions = SocketOptions.builder()
|
||||
.connectTimeout(Duration.ofMillis(redisTimeout))
|
||||
.keepAlive(true)
|
||||
.build();
|
||||
|
||||
ClientOptions clientOptions = ClientOptions.builder()
|
||||
.socketOptions(socketOptions)
|
||||
.autoReconnect(true)
|
||||
.build();
|
||||
|
||||
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||
.commandTimeout(Duration.ofMillis(redisTimeout))
|
||||
.clientOptions(clientOptions)
|
||||
.build();
|
||||
|
||||
// afterPropertiesSet() 제거: Spring이 자동으로 호출함
|
||||
return new LettuceConnectionFactory(config, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* ObjectMapper for Redis (Java 8 Date/Time 지원)
|
||||
*/
|
||||
@Bean
|
||||
public ObjectMapper redisObjectMapper() {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
// Java 8 Date/Time 모듈 등록
|
||||
mapper.registerModule(new JavaTimeModule());
|
||||
|
||||
// Timestamp 대신 ISO-8601 형식으로 직렬화
|
||||
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
|
||||
return mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* - Key: String
|
||||
* - Value: JSON (Jackson)
|
||||
* - Value: JSON (Jackson with Java 8 Date/Time support)
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
@@ -63,9 +107,12 @@ public class RedisConfig {
|
||||
template.setKeySerializer(new StringRedisSerializer());
|
||||
template.setHashKeySerializer(new StringRedisSerializer());
|
||||
|
||||
// Value Serializer: JSON
|
||||
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||
// Value Serializer: JSON with Java 8 Date/Time support
|
||||
GenericJackson2JsonRedisSerializer serializer =
|
||||
new GenericJackson2JsonRedisSerializer(redisObjectMapper());
|
||||
|
||||
template.setValueSerializer(serializer);
|
||||
template.setHashValueSerializer(serializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
|
||||
@@ -5,8 +5,8 @@ import com.kt.ai.model.enums.CircuitBreakerState;
|
||||
import com.kt.ai.model.enums.ServiceStatus;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -23,22 +23,27 @@ import java.time.LocalDateTime;
|
||||
@Slf4j
|
||||
@Tag(name = "Health Check", description = "서비스 상태 확인")
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class HealthController {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
@Autowired(required = false)
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* 서비스 헬스체크
|
||||
*/
|
||||
@Operation(summary = "서비스 헬스체크", description = "AI Service 상태 및 외부 연동 확인")
|
||||
@GetMapping("/health")
|
||||
@GetMapping("/api/v1/ai-service/health")
|
||||
public ResponseEntity<HealthCheckResponse> healthCheck() {
|
||||
// Redis 상태 확인
|
||||
ServiceStatus redisStatus = checkRedis();
|
||||
|
||||
// 전체 서비스 상태
|
||||
ServiceStatus overallStatus = (redisStatus == ServiceStatus.UP) ? ServiceStatus.UP : ServiceStatus.DEGRADED;
|
||||
// 전체 서비스 상태 (Redis가 DOWN이면 DEGRADED, UNKNOWN이면 UP으로 처리)
|
||||
ServiceStatus overallStatus;
|
||||
if (redisStatus == ServiceStatus.DOWN) {
|
||||
overallStatus = ServiceStatus.DEGRADED;
|
||||
} else {
|
||||
overallStatus = ServiceStatus.UP;
|
||||
}
|
||||
|
||||
HealthCheckResponse.Services services = HealthCheckResponse.Services.builder()
|
||||
.kafka(ServiceStatus.UP) // TODO: 실제 Kafka 상태 확인
|
||||
@@ -61,11 +66,25 @@ public class HealthController {
|
||||
* Redis 연결 상태 확인
|
||||
*/
|
||||
private ServiceStatus checkRedis() {
|
||||
// RedisTemplate이 주입되지 않은 경우 (로컬 환경 등)
|
||||
if (redisTemplate == null) {
|
||||
log.warn("RedisTemplate이 주입되지 않았습니다. Redis 상태를 UNKNOWN으로 표시합니다.");
|
||||
return ServiceStatus.UNKNOWN;
|
||||
}
|
||||
|
||||
try {
|
||||
redisTemplate.getConnectionFactory().getConnection().ping();
|
||||
log.debug("Redis 연결 테스트 시작...");
|
||||
String pong = redisTemplate.getConnectionFactory().getConnection().ping();
|
||||
log.info("✅ Redis 연결 성공! PING 응답: {}", pong);
|
||||
return ServiceStatus.UP;
|
||||
} catch (Exception e) {
|
||||
log.error("Redis 연결 실패", e);
|
||||
log.error("❌ Redis 연결 실패", e);
|
||||
log.error("상세 오류 정보:");
|
||||
log.error(" - 오류 타입: {}", e.getClass().getName());
|
||||
log.error(" - 오류 메시지: {}", e.getMessage());
|
||||
if (e.getCause() != null) {
|
||||
log.error(" - 원인: {}", e.getCause().getMessage());
|
||||
}
|
||||
return ServiceStatus.DOWN;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.kt.ai.controller;
|
||||
|
||||
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||
import com.kt.ai.model.enums.JobStatus;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@@ -12,6 +14,9 @@ import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Internal Job Controller
|
||||
* Event Service에서 호출하는 내부 API
|
||||
@@ -22,11 +27,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/internal/jobs")
|
||||
@RequestMapping("/api/v1/ai-service/internal/jobs")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalJobController {
|
||||
|
||||
private final JobStatusService jobStatusService;
|
||||
private final CacheService cacheService;
|
||||
|
||||
/**
|
||||
* 작업 상태 조회
|
||||
@@ -38,4 +44,49 @@ public class InternalJobController {
|
||||
JobStatusResponse response = jobStatusService.getJobStatus(jobId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 디버그: Job 상태 테스트 데이터 생성
|
||||
*/
|
||||
@Operation(summary = "Job 테스트 데이터 생성 (디버그)", description = "Redis에 샘플 Job 상태 데이터 저장")
|
||||
@GetMapping("/debug/create-test-job/{jobId}")
|
||||
public ResponseEntity<Map<String, Object>> createTestJob(@PathVariable String jobId) {
|
||||
log.info("Job 테스트 데이터 생성 요청: jobId={}", jobId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 다양한 상태의 테스트 데이터 생성
|
||||
JobStatus[] statuses = JobStatus.values();
|
||||
|
||||
// 요청된 jobId로 PROCESSING 상태 데이터 생성
|
||||
jobStatusService.updateJobStatus(jobId, JobStatus.PROCESSING, "AI 추천 생성 중 (50%)");
|
||||
|
||||
// 추가 샘플 데이터 생성 (다양한 상태)
|
||||
jobStatusService.updateJobStatus(jobId + "-pending", JobStatus.PENDING, "대기 중");
|
||||
jobStatusService.updateJobStatus(jobId + "-completed", JobStatus.COMPLETED, "AI 추천 완료");
|
||||
jobStatusService.updateJobStatus(jobId + "-failed", JobStatus.FAILED, "AI API 호출 실패");
|
||||
|
||||
// 저장 확인
|
||||
Object saved = cacheService.getJobStatus(jobId);
|
||||
|
||||
result.put("success", true);
|
||||
result.put("jobId", jobId);
|
||||
result.put("saved", saved != null);
|
||||
result.put("data", saved);
|
||||
result.put("additionalSamples", Map.of(
|
||||
"pending", jobId + "-pending",
|
||||
"completed", jobId + "-completed",
|
||||
"failed", jobId + "-failed"
|
||||
));
|
||||
|
||||
log.info("Job 테스트 데이터 생성 완료: jobId={}, saved={}", jobId, saved != null);
|
||||
} catch (Exception e) {
|
||||
log.error("Job 테스트 데이터 생성 실패: jobId={}", jobId, e);
|
||||
result.put("success", false);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
+224
-1
@@ -1,17 +1,26 @@
|
||||
package com.kt.ai.controller;
|
||||
|
||||
import com.kt.ai.model.dto.response.AIRecommendationResult;
|
||||
import com.kt.ai.model.dto.response.EventRecommendation;
|
||||
import com.kt.ai.model.dto.response.TrendAnalysis;
|
||||
import com.kt.ai.service.AIRecommendationService;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Internal Recommendation Controller
|
||||
* Event Service에서 호출하는 내부 API
|
||||
@@ -22,11 +31,13 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
@Slf4j
|
||||
@Tag(name = "Internal API", description = "내부 서비스 간 통신용 API")
|
||||
@RestController
|
||||
@RequestMapping("/internal/recommendations")
|
||||
@RequestMapping("/api/v1/ai-service/internal/recommendations")
|
||||
@RequiredArgsConstructor
|
||||
public class InternalRecommendationController {
|
||||
|
||||
private final AIRecommendationService aiRecommendationService;
|
||||
private final CacheService cacheService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
/**
|
||||
* AI 추천 결과 조회
|
||||
@@ -38,4 +49,216 @@ public class InternalRecommendationController {
|
||||
AIRecommendationResult response = aiRecommendationService.getRecommendation(eventId);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 디버그: 모든 키 조회
|
||||
*/
|
||||
@Operation(summary = "Redis 키 조회 (디버그)", description = "Redis에 저장된 모든 키 조회")
|
||||
@GetMapping("/debug/redis-keys")
|
||||
public ResponseEntity<Map<String, Object>> debugRedisKeys() {
|
||||
log.info("Redis 키 디버그 요청");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 모든 ai:* 키 조회
|
||||
Set<String> keys = redisTemplate.keys("ai:*");
|
||||
result.put("totalKeys", keys != null ? keys.size() : 0);
|
||||
result.put("keys", keys);
|
||||
|
||||
// 특정 키의 값 조회
|
||||
if (keys != null && !keys.isEmpty()) {
|
||||
Map<String, Object> values = new HashMap<>();
|
||||
for (String key : keys) {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
values.put(key, value);
|
||||
}
|
||||
result.put("values", values);
|
||||
}
|
||||
|
||||
log.info("Redis 키 조회 성공: {} 개의 키 발견", keys != null ? keys.size() : 0);
|
||||
} catch (Exception e) {
|
||||
log.error("Redis 키 조회 실패", e);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 디버그: 특정 키 조회
|
||||
*/
|
||||
@Operation(summary = "Redis 특정 키 조회 (디버그)", description = "Redis에서 특정 키의 값 조회")
|
||||
@GetMapping("/debug/redis-key/{key}")
|
||||
public ResponseEntity<Map<String, Object>> debugRedisKey(@PathVariable String key) {
|
||||
log.info("Redis 특정 키 조회 요청: key={}", key);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("key", key);
|
||||
|
||||
try {
|
||||
Object value = redisTemplate.opsForValue().get(key);
|
||||
result.put("exists", value != null);
|
||||
result.put("value", value);
|
||||
|
||||
log.info("Redis 키 조회: key={}, exists={}", key, value != null);
|
||||
} catch (Exception e) {
|
||||
log.error("Redis 키 조회 실패: key={}", key, e);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 디버그: 모든 database 검색
|
||||
*/
|
||||
@Operation(summary = "모든 Redis DB 검색 (디버그)", description = "Redis database 0~15에서 ai:* 키 검색")
|
||||
@GetMapping("/debug/search-all-databases")
|
||||
public ResponseEntity<Map<String, Object>> searchAllDatabases() {
|
||||
log.info("모든 Redis database 검색 시작");
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
Map<Integer, Set<String>> databaseKeys = new HashMap<>();
|
||||
|
||||
try {
|
||||
// Redis connection factory를 통해 database 변경하며 검색
|
||||
var connectionFactory = redisTemplate.getConnectionFactory();
|
||||
|
||||
for (int db = 0; db < 16; db++) {
|
||||
try {
|
||||
var connection = connectionFactory.getConnection();
|
||||
connection.select(db);
|
||||
|
||||
Set<byte[]> keyBytes = connection.keys("ai:*".getBytes());
|
||||
if (keyBytes != null && !keyBytes.isEmpty()) {
|
||||
Set<String> keys = new java.util.HashSet<>();
|
||||
for (byte[] keyByte : keyBytes) {
|
||||
keys.add(new String(keyByte));
|
||||
}
|
||||
databaseKeys.put(db, keys);
|
||||
log.info("Database {} 에서 {} 개의 ai:* 키 발견", db, keys.size());
|
||||
}
|
||||
|
||||
connection.close();
|
||||
} catch (Exception e) {
|
||||
log.warn("Database {} 검색 실패: {}", db, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
result.put("databasesWithKeys", databaseKeys);
|
||||
result.put("totalDatabases", databaseKeys.size());
|
||||
|
||||
log.info("모든 database 검색 완료: {} 개의 database에 키 존재", databaseKeys.size());
|
||||
} catch (Exception e) {
|
||||
log.error("모든 database 검색 실패", e);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 디버그: 테스트 데이터 생성
|
||||
*/
|
||||
@Operation(summary = "테스트 데이터 생성 (디버그)", description = "Redis에 샘플 AI 추천 데이터 저장")
|
||||
@GetMapping("/debug/create-test-data/{eventId}")
|
||||
public ResponseEntity<Map<String, Object>> createTestData(@PathVariable String eventId) {
|
||||
log.info("테스트 데이터 생성 요청: eventId={}", eventId);
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
|
||||
try {
|
||||
// 샘플 AI 추천 결과 생성
|
||||
AIRecommendationResult testData = AIRecommendationResult.builder()
|
||||
.eventId(eventId)
|
||||
.trendAnalysis(TrendAnalysis.builder()
|
||||
.industryTrends(List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("BBQ 고기집")
|
||||
.relevance(0.95)
|
||||
.description("음식점 업종, 고기 구이 인기 트렌드")
|
||||
.build()
|
||||
))
|
||||
.regionalTrends(List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("강남 맛집")
|
||||
.relevance(0.90)
|
||||
.description("강남구 지역 외식 인기 증가")
|
||||
.build()
|
||||
))
|
||||
.seasonalTrends(List.of(
|
||||
TrendAnalysis.TrendKeyword.builder()
|
||||
.keyword("봄나들이 외식")
|
||||
.relevance(0.85)
|
||||
.description("봄철 야외 활동 및 외식 증가")
|
||||
.build()
|
||||
))
|
||||
.build())
|
||||
.recommendations(List.of(
|
||||
EventRecommendation.builder()
|
||||
.optionNumber(1)
|
||||
.concept("SNS 이벤트")
|
||||
.title("인스타그램 후기 이벤트")
|
||||
.description("음식 사진을 인스타그램에 올리고 해시태그를 달면 할인 쿠폰 제공")
|
||||
.targetAudience("20-30대 SNS 활동층")
|
||||
.duration(EventRecommendation.Duration.builder()
|
||||
.recommendedDays(14)
|
||||
.recommendedPeriod("2주")
|
||||
.build())
|
||||
.mechanics(EventRecommendation.Mechanics.builder()
|
||||
.type(com.kt.ai.model.enums.EventMechanicsType.DISCOUNT)
|
||||
.details("인스타그램 게시물 작성 시 10% 할인")
|
||||
.build())
|
||||
.promotionChannels(List.of("Instagram", "Facebook", "매장 포스터"))
|
||||
.estimatedCost(EventRecommendation.EstimatedCost.builder()
|
||||
.min(100000)
|
||||
.max(200000)
|
||||
.breakdown(Map.of(
|
||||
"할인비용", 150000,
|
||||
"홍보비", 50000
|
||||
))
|
||||
.build())
|
||||
.expectedMetrics(com.kt.ai.model.dto.response.ExpectedMetrics.builder()
|
||||
.newCustomers(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||
.min(30.0)
|
||||
.max(50.0)
|
||||
.build())
|
||||
.revenueIncrease(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||
.min(10.0)
|
||||
.max(20.0)
|
||||
.build())
|
||||
.roi(com.kt.ai.model.dto.response.ExpectedMetrics.Range.builder()
|
||||
.min(100.0)
|
||||
.max(150.0)
|
||||
.build())
|
||||
.build())
|
||||
.differentiator("SNS를 활용한 바이럴 마케팅")
|
||||
.build()
|
||||
))
|
||||
.generatedAt(java.time.LocalDateTime.now())
|
||||
.expiresAt(java.time.LocalDateTime.now().plusDays(1))
|
||||
.aiProvider(com.kt.ai.model.enums.AIProvider.CLAUDE)
|
||||
.build();
|
||||
|
||||
// Redis에 저장
|
||||
cacheService.saveRecommendation(eventId, testData);
|
||||
|
||||
// 저장 확인
|
||||
Object saved = cacheService.getRecommendation(eventId);
|
||||
|
||||
result.put("success", true);
|
||||
result.put("eventId", eventId);
|
||||
result.put("saved", saved != null);
|
||||
result.put("data", saved);
|
||||
|
||||
log.info("테스트 데이터 생성 완료: eventId={}, saved={}", eventId, saved != null);
|
||||
} catch (Exception e) {
|
||||
log.error("테스트 데이터 생성 실패: eventId={}", eventId, e);
|
||||
result.put("success", false);
|
||||
result.put("error", e.getMessage());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
@@ -89,6 +90,29 @@ public class GlobalExceptionHandler {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 리소스를 찾을 수 없는 예외 처리 (favicon.ico 등)
|
||||
* WARN 레벨로 로깅하여 에러 로그 오염 방지
|
||||
*/
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public ResponseEntity<ErrorResponse> handleNoResourceFoundException(NoResourceFoundException ex) {
|
||||
// favicon.ico 등 브라우저가 자동으로 요청하는 리소스는 DEBUG 레벨로 로깅
|
||||
String resourcePath = ex.getResourcePath();
|
||||
if (resourcePath != null && (resourcePath.contains("favicon") || resourcePath.endsWith(".ico"))) {
|
||||
log.debug("Static resource not found (expected): {}", resourcePath);
|
||||
} else {
|
||||
log.warn("Static resource not found: {}", resourcePath);
|
||||
}
|
||||
|
||||
ErrorResponse error = ErrorResponse.builder()
|
||||
.code("RESOURCE_NOT_FOUND")
|
||||
.message("요청하신 리소스를 찾을 수 없습니다")
|
||||
.timestamp(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* 일반 예외 처리
|
||||
*/
|
||||
|
||||
@@ -20,5 +20,10 @@ public enum ServiceStatus {
|
||||
/**
|
||||
* 성능 저하
|
||||
*/
|
||||
DEGRADED
|
||||
DEGRADED,
|
||||
|
||||
/**
|
||||
* 상태 알 수 없음 (설정되지 않음)
|
||||
*/
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@@ -184,7 +184,6 @@ public class AIRecommendationService {
|
||||
ClaudeResponse response = claudeApiClient.sendMessage(
|
||||
apiKey,
|
||||
anthropicVersion,
|
||||
"application/json",
|
||||
request
|
||||
);
|
||||
|
||||
|
||||
@@ -93,7 +93,6 @@ public class TrendAnalysisService {
|
||||
ClaudeResponse response = claudeApiClient.sendMessage(
|
||||
apiKey,
|
||||
anthropicVersion,
|
||||
"application/json",
|
||||
request
|
||||
);
|
||||
|
||||
|
||||
@@ -5,10 +5,10 @@ spring:
|
||||
# Redis Configuration
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.214.210.71}
|
||||
host: ${REDIS_HOST:redis-external} # Production: redis-external, Local: 20.214.210.71
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:3} # AI Service uses database 3
|
||||
database: ${REDIS_DATABASE:0} # AI Service uses database 3
|
||||
timeout: ${REDIS_TIMEOUT:3000}
|
||||
lettuce:
|
||||
pool:
|
||||
@@ -33,26 +33,6 @@ spring:
|
||||
listener:
|
||||
ack-mode: manual
|
||||
|
||||
# JPA Configuration (Not used but included for consistency)
|
||||
jpa:
|
||||
open-in-view: false
|
||||
show-sql: false
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true
|
||||
use_sql_comments: false
|
||||
|
||||
# Database Configuration (Not used but included for consistency)
|
||||
datasource:
|
||||
url: jdbc:postgresql://${DB_HOST:4.230.112.141}:${DB_PORT:5432}/${DB_NAME:aidb}
|
||||
username: ${DB_USERNAME:eventuser}
|
||||
password: ${DB_PASSWORD:}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 10
|
||||
minimum-idle: 2
|
||||
connection-timeout: 30000
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
@@ -119,6 +99,13 @@ logging:
|
||||
pattern:
|
||||
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
|
||||
file:
|
||||
name: ${LOG_FILE:logs/ai-service.log}
|
||||
logback:
|
||||
rollingpolicy:
|
||||
max-file-size: 10MB
|
||||
max-history: 7
|
||||
total-size-cap: 100MB
|
||||
|
||||
# Kafka Topics Configuration
|
||||
kafka:
|
||||
@@ -131,8 +118,10 @@ ai:
|
||||
claude:
|
||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||
api-key: ${CLAUDE_API_KEY:}
|
||||
anthropic-version: ${CLAUDE_ANTHROPIC_VERSION:2023-06-01}
|
||||
model: ${CLAUDE_MODEL:claude-3-5-sonnet-20241022}
|
||||
max-tokens: ${CLAUDE_MAX_TOKENS:4096}
|
||||
temperature: ${CLAUDE_TEMPERATURE:0.7}
|
||||
timeout: ${CLAUDE_TIMEOUT:300000} # 5 minutes
|
||||
gpt4:
|
||||
api-url: ${GPT4_API_URL:https://api.openai.com/v1/chat/completions}
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.kt.ai.test.integration.kafka;
|
||||
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.awaitility.Awaitility.await;
|
||||
|
||||
/**
|
||||
* AIJobConsumer Kafka 통합 테스트
|
||||
*
|
||||
* 실제 Kafka 브로커가 실행 중이어야 합니다.
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@DisplayName("AIJobConsumer Kafka 통합 테스트")
|
||||
class AIJobConsumerIntegrationTest {
|
||||
|
||||
@Value("${spring.kafka.bootstrap-servers}")
|
||||
private String bootstrapServers;
|
||||
|
||||
@Value("${kafka.topics.ai-job}")
|
||||
private String aiJobTopic;
|
||||
|
||||
@Autowired
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
@Autowired
|
||||
private CacheService cacheService;
|
||||
|
||||
private KafkaTestProducer testProducer;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testProducer = new KafkaTestProducer(bootstrapServers, aiJobTopic);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
if (testProducer != null) {
|
||||
testProducer.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid AI job message, When send to Kafka, Then consumer processes and saves to Redis")
|
||||
void givenValidAIJobMessage_whenSendToKafka_thenConsumerProcessesAndSavesToRedis() {
|
||||
// Given
|
||||
String jobId = "test-job-" + System.currentTimeMillis();
|
||||
String eventId = "test-event-" + System.currentTimeMillis();
|
||||
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobId, eventId);
|
||||
|
||||
// When
|
||||
testProducer.sendAIJobMessage(message);
|
||||
|
||||
// Then - Kafka Consumer가 메시지를 처리하고 Redis에 저장할 때까지 대기
|
||||
await()
|
||||
.atMost(30, TimeUnit.SECONDS)
|
||||
.pollInterval(1, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
// Job 상태가 Redis에 저장되었는지 확인
|
||||
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||
assertThat(jobStatus).isNotNull();
|
||||
System.out.println("Job 상태 확인: " + jobStatus);
|
||||
});
|
||||
|
||||
// 최종 상태 확인 (COMPLETED 또는 FAILED)
|
||||
await()
|
||||
.atMost(60, TimeUnit.SECONDS)
|
||||
.pollInterval(2, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
Object jobStatus = cacheService.getJobStatus(jobId);
|
||||
assertThat(jobStatus).isNotNull();
|
||||
|
||||
// AI 추천 결과도 저장되었는지 확인 (COMPLETED 상태인 경우)
|
||||
Object recommendation = cacheService.getRecommendation(eventId);
|
||||
System.out.println("AI 추천 결과: " + (recommendation != null ? "있음" : "없음"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given multiple messages, When send to Kafka, Then all messages are processed")
|
||||
void givenMultipleMessages_whenSendToKafka_thenAllMessagesAreProcessed() {
|
||||
// Given
|
||||
int messageCount = 3;
|
||||
String[] jobIds = new String[messageCount];
|
||||
String[] eventIds = new String[messageCount];
|
||||
|
||||
// When - 여러 메시지 전송
|
||||
for (int i = 0; i < messageCount; i++) {
|
||||
jobIds[i] = "batch-job-" + i + "-" + System.currentTimeMillis();
|
||||
eventIds[i] = "batch-event-" + i + "-" + System.currentTimeMillis();
|
||||
AIJobMessage message = KafkaTestProducer.createSampleMessage(jobIds[i], eventIds[i]);
|
||||
testProducer.sendAIJobMessage(message);
|
||||
}
|
||||
|
||||
// Then - 모든 메시지가 처리되었는지 확인
|
||||
await()
|
||||
.atMost(90, TimeUnit.SECONDS)
|
||||
.pollInterval(2, TimeUnit.SECONDS)
|
||||
.untilAsserted(() -> {
|
||||
int processedCount = 0;
|
||||
for (int i = 0; i < messageCount; i++) {
|
||||
Object jobStatus = cacheService.getJobStatus(jobIds[i]);
|
||||
if (jobStatus != null) {
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
assertThat(processedCount).isEqualTo(messageCount);
|
||||
System.out.println("처리된 메시지 수: " + processedCount + "/" + messageCount);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.kt.ai.test.integration.kafka;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.kafka.clients.producer.KafkaProducer;
|
||||
import org.apache.kafka.clients.producer.ProducerConfig;
|
||||
import org.apache.kafka.clients.producer.ProducerRecord;
|
||||
import org.apache.kafka.clients.producer.RecordMetadata;
|
||||
import org.apache.kafka.common.serialization.StringSerializer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
/**
|
||||
* Kafka 테스트용 Producer 유틸리티
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
public class KafkaTestProducer {
|
||||
|
||||
private final KafkaProducer<String, String> producer;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final String topic;
|
||||
|
||||
public KafkaTestProducer(String bootstrapServers, String topic) {
|
||||
this.topic = topic;
|
||||
this.objectMapper = new ObjectMapper();
|
||||
this.objectMapper.registerModule(new JavaTimeModule());
|
||||
|
||||
Properties props = new Properties();
|
||||
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
|
||||
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
|
||||
props.put(ProducerConfig.ACKS_CONFIG, "all");
|
||||
props.put(ProducerConfig.RETRIES_CONFIG, 3);
|
||||
|
||||
this.producer = new KafkaProducer<>(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Job 메시지 전송
|
||||
*/
|
||||
public RecordMetadata sendAIJobMessage(AIJobMessage message) {
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(message);
|
||||
ProducerRecord<String, String> record = new ProducerRecord<>(topic, message.getJobId(), json);
|
||||
|
||||
Future<RecordMetadata> future = producer.send(record);
|
||||
RecordMetadata metadata = future.get();
|
||||
|
||||
log.info("Kafka 메시지 전송 성공: topic={}, partition={}, offset={}, jobId={}",
|
||||
metadata.topic(), metadata.partition(), metadata.offset(), message.getJobId());
|
||||
|
||||
return metadata;
|
||||
} catch (Exception e) {
|
||||
log.error("Kafka 메시지 전송 실패: jobId={}", message.getJobId(), e);
|
||||
throw new RuntimeException("Kafka 메시지 전송 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트용 샘플 메시지 생성
|
||||
*/
|
||||
public static AIJobMessage createSampleMessage(String jobId, String eventId) {
|
||||
return AIJobMessage.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.objective("신규 고객 유치")
|
||||
.industry("음식점")
|
||||
.region("강남구")
|
||||
.storeName("테스트 BBQ 레스토랑")
|
||||
.targetAudience("20-30대 직장인")
|
||||
.budget(500000)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Producer 종료
|
||||
*/
|
||||
public void close() {
|
||||
if (producer != null) {
|
||||
producer.close();
|
||||
log.info("Kafka Producer 종료");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.kt.ai.test.manual;
|
||||
|
||||
import com.kt.ai.kafka.message.AIJobMessage;
|
||||
import com.kt.ai.test.integration.kafka.KafkaTestProducer;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* Kafka 수동 테스트
|
||||
*
|
||||
* 이 클래스는 main 메서드를 실행하여 Kafka에 메시지를 직접 전송할 수 있습니다.
|
||||
* IDE에서 직접 실행하거나 Gradle로 실행할 수 있습니다.
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
public class KafkaManualTest {
|
||||
|
||||
// Kafka 설정 (환경에 맞게 수정)
|
||||
private static final String BOOTSTRAP_SERVERS = "20.249.182.13:9095,4.217.131.59:9095";
|
||||
private static final String TOPIC = "ai-event-generation-job";
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("=== Kafka 수동 테스트 시작 ===");
|
||||
System.out.println("Bootstrap Servers: " + BOOTSTRAP_SERVERS);
|
||||
System.out.println("Topic: " + TOPIC);
|
||||
|
||||
KafkaTestProducer producer = new KafkaTestProducer(BOOTSTRAP_SERVERS, TOPIC);
|
||||
|
||||
try {
|
||||
// 테스트 메시지 1: 기본 메시지
|
||||
AIJobMessage message1 = createTestMessage(
|
||||
"manual-job-001",
|
||||
"manual-event-001",
|
||||
"신규 고객 유치",
|
||||
"음식점",
|
||||
"강남구",
|
||||
"테스트 BBQ 레스토랑",
|
||||
500000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 1] 전송 중...");
|
||||
producer.sendAIJobMessage(message1);
|
||||
System.out.println("[메시지 1] 전송 완료");
|
||||
|
||||
// 테스트 메시지 2: 다른 업종
|
||||
AIJobMessage message2 = createTestMessage(
|
||||
"manual-job-002",
|
||||
"manual-event-002",
|
||||
"재방문 유도",
|
||||
"카페",
|
||||
"서초구",
|
||||
"테스트 카페",
|
||||
300000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 2] 전송 중...");
|
||||
producer.sendAIJobMessage(message2);
|
||||
System.out.println("[메시지 2] 전송 완료");
|
||||
|
||||
// 테스트 메시지 3: 저예산
|
||||
AIJobMessage message3 = createTestMessage(
|
||||
"manual-job-003",
|
||||
"manual-event-003",
|
||||
"매출 증대",
|
||||
"소매점",
|
||||
"마포구",
|
||||
"테스트 편의점",
|
||||
100000
|
||||
);
|
||||
|
||||
System.out.println("\n[메시지 3] 전송 중...");
|
||||
producer.sendAIJobMessage(message3);
|
||||
System.out.println("[메시지 3] 전송 완료");
|
||||
|
||||
System.out.println("\n=== 모든 메시지 전송 완료 ===");
|
||||
System.out.println("\n다음 API로 결과를 확인하세요:");
|
||||
System.out.println("- Job 상태: GET http://localhost:8083/api/v1/ai-service/internal/jobs/{jobId}/status");
|
||||
System.out.println("- AI 추천: GET http://localhost:8083/api/v1/ai-service/internal/recommendations/{eventId}");
|
||||
System.out.println("\n예시:");
|
||||
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/jobs/manual-job-001/status");
|
||||
System.out.println(" curl http://localhost:8083/api/v1/ai-service/internal/recommendations/manual-event-001");
|
||||
|
||||
} catch (Exception e) {
|
||||
System.err.println("에러 발생: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
producer.close();
|
||||
System.out.println("\n=== Kafka Producer 종료 ===");
|
||||
}
|
||||
}
|
||||
|
||||
private static AIJobMessage createTestMessage(
|
||||
String jobId,
|
||||
String eventId,
|
||||
String objective,
|
||||
String industry,
|
||||
String region,
|
||||
String storeName,
|
||||
int budget
|
||||
) {
|
||||
return AIJobMessage.builder()
|
||||
.jobId(jobId)
|
||||
.eventId(eventId)
|
||||
.objective(objective)
|
||||
.industry(industry)
|
||||
.region(region)
|
||||
.storeName(storeName)
|
||||
.targetAudience("20-40대 고객")
|
||||
.budget(budget)
|
||||
.requestedAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.kt.ai.test.unit.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kt.ai.controller.InternalJobController;
|
||||
import com.kt.ai.exception.JobNotFoundException;
|
||||
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||
import com.kt.ai.model.enums.JobStatus;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.hamcrest.Matchers.*;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
/**
|
||||
* InternalJobController 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@WebMvcTest(controllers = InternalJobController.class,
|
||||
excludeAutoConfiguration = {org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration.class})
|
||||
@DisplayName("InternalJobController 단위 테스트")
|
||||
class InternalJobControllerUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String INVALID_JOB_ID = "job-999";
|
||||
private static final String BASE_URL = "/api/v1/ai-service/internal/jobs";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@MockBean
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
@MockBean
|
||||
private CacheService cacheService;
|
||||
|
||||
private JobStatusResponse sampleJobStatusResponse;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.PROCESSING)
|
||||
.progress(50)
|
||||
.message("AI 추천 생성 중 (50%)")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== GET /{jobId}/status 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return 200 with job status")
|
||||
void givenExistingJob_whenGetStatus_thenReturn200WithJobStatus() throws Exception {
|
||||
// Given
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||
.andExpect(jsonPath("$.status", is("PROCESSING")))
|
||||
.andExpect(jsonPath("$.progress", is(50)))
|
||||
.andExpect(jsonPath("$.message", is("AI 추천 생성 중 (50%)")))
|
||||
.andExpect(jsonPath("$.createdAt", notNullValue()));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then return 404")
|
||||
void givenNonExistingJob_whenGetStatus_thenReturn404() throws Exception {
|
||||
// Given
|
||||
when(jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||
.thenThrow(new JobNotFoundException(INVALID_JOB_ID));
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", INVALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isNotFound())
|
||||
.andExpect(jsonPath("$.code", is("JOB_NOT_FOUND")))
|
||||
.andExpect(jsonPath("$.message", containsString(INVALID_JOB_ID)));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given completed job, When get status, Then return COMPLETED status with 100% progress")
|
||||
void givenCompletedJob_whenGetStatus_thenReturnCompletedStatus() throws Exception {
|
||||
// Given
|
||||
JobStatusResponse completedResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.COMPLETED)
|
||||
.progress(100)
|
||||
.message("AI 추천 완료")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(completedResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status", is("COMPLETED")))
|
||||
.andExpect(jsonPath("$.progress", is(100)));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given failed job, When get status, Then return FAILED status")
|
||||
void givenFailedJob_whenGetStatus_thenReturnFailedStatus() throws Exception {
|
||||
// Given
|
||||
JobStatusResponse failedResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.FAILED)
|
||||
.progress(0)
|
||||
.message("AI API 호출 실패")
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
|
||||
when(jobStatusService.getJobStatus(VALID_JOB_ID)).thenReturn(failedResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/{jobId}/status", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.status", is("FAILED")))
|
||||
.andExpect(jsonPath("$.progress", is(0)))
|
||||
.andExpect(jsonPath("$.message", containsString("실패")));
|
||||
|
||||
verify(jobStatusService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
|
||||
// ========== 디버그 엔드포인트 테스트 (선택사항) ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid jobId, When create test job, Then return 200 with test data")
|
||||
void givenValidJobId_whenCreateTestJob_thenReturn200WithTestData() throws Exception {
|
||||
// Given
|
||||
doNothing().when(jobStatusService).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When & Then
|
||||
mockMvc.perform(get(BASE_URL + "/debug/create-test-job/{jobId}", VALID_JOB_ID)
|
||||
.contentType(MediaType.APPLICATION_JSON))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.success", is(true)))
|
||||
.andExpect(jsonPath("$.jobId", is(VALID_JOB_ID)))
|
||||
.andExpect(jsonPath("$.saved", is(true)))
|
||||
.andExpect(jsonPath("$.additionalSamples", notNullValue()));
|
||||
|
||||
// updateJobStatus가 4번 호출되어야 함 (main + 3 additional samples)
|
||||
verify(jobStatusService, times(4)).updateJobStatus(anyString(), org.mockito.ArgumentMatchers.any(JobStatus.class), anyString());
|
||||
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package com.kt.ai.test.unit.service;
|
||||
|
||||
import com.kt.ai.service.CacheService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
|
||||
/**
|
||||
* CacheService 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("CacheService 단위 테스트")
|
||||
class CacheServiceUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_KEY = "test:key";
|
||||
private static final String VALID_VALUE = "test-value";
|
||||
private static final long VALID_TTL = 3600L;
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String VALID_EVENT_ID = "evt-001";
|
||||
private static final String VALID_INDUSTRY = "음식점";
|
||||
private static final String VALID_REGION = "강남구";
|
||||
|
||||
@Mock
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@Mock
|
||||
private ValueOperations<String, Object> valueOperations;
|
||||
|
||||
@InjectMocks
|
||||
private CacheService cacheService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// TTL 값 설정
|
||||
ReflectionTestUtils.setField(cacheService, "recommendationTtl", 86400L);
|
||||
ReflectionTestUtils.setField(cacheService, "jobStatusTtl", 86400L);
|
||||
ReflectionTestUtils.setField(cacheService, "trendTtl", 3600L);
|
||||
|
||||
// RedisTemplate Mock 설정 (lenient를 사용하여 모든 테스트에서 사용하지 않아도 됨)
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
||||
}
|
||||
|
||||
// ========== set() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid key and value, When set, Then success")
|
||||
void givenValidKeyAndValue_whenSet_thenSuccess() {
|
||||
// Given
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given Redis exception, When set, Then log error and continue")
|
||||
void givenRedisException_whenSet_thenLogErrorAndContinue() {
|
||||
// Given
|
||||
doThrow(new RuntimeException("Redis connection failed"))
|
||||
.when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When & Then (예외가 전파되지 않아야 함)
|
||||
cacheService.set(VALID_KEY, VALID_VALUE, VALID_TTL);
|
||||
|
||||
verify(valueOperations, times(1))
|
||||
.set(VALID_KEY, VALID_VALUE, VALID_TTL, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== get() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing key, When get, Then return value")
|
||||
void givenExistingKey_whenGet_thenReturnValue() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY)).thenReturn(VALID_VALUE);
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(VALID_VALUE);
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing key, When get, Then return null")
|
||||
void givenNonExistingKey_whenGet_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY)).thenReturn(null);
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given Redis exception, When get, Then return null")
|
||||
void givenRedisException_whenGet_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get(VALID_KEY))
|
||||
.thenThrow(new RuntimeException("Redis connection failed"));
|
||||
|
||||
// When
|
||||
Object result = cacheService.get(VALID_KEY);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get(VALID_KEY);
|
||||
}
|
||||
|
||||
// ========== delete() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid key, When delete, Then invoke RedisTemplate delete")
|
||||
void givenValidKey_whenDelete_thenInvokeRedisTemplateDelete() {
|
||||
// Given - No specific setup needed
|
||||
|
||||
// When
|
||||
cacheService.delete(VALID_KEY);
|
||||
|
||||
// Then
|
||||
verify(redisTemplate, times(1)).delete(VALID_KEY);
|
||||
}
|
||||
|
||||
// ========== saveJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid job status, When save, Then success")
|
||||
void givenValidJobStatus_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object jobStatus = "PROCESSING";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveJobStatus(VALID_JOB_ID, jobStatus);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:job:status:" + VALID_JOB_ID, jobStatus, 86400L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return status")
|
||||
void givenExistingJob_whenGetStatus_thenReturnStatus() {
|
||||
// Given
|
||||
Object expectedStatus = "COMPLETED";
|
||||
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(expectedStatus);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedStatus);
|
||||
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then return null")
|
||||
void givenNonExistingJob_whenGetStatus_thenReturnNull() {
|
||||
// Given
|
||||
when(valueOperations.get("ai:job:status:" + VALID_JOB_ID)).thenReturn(null);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNull();
|
||||
verify(valueOperations, times(1)).get("ai:job:status:" + VALID_JOB_ID);
|
||||
}
|
||||
|
||||
// ========== saveRecommendation() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid recommendation, When save, Then success")
|
||||
void givenValidRecommendation_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object recommendation = "recommendation-data";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveRecommendation(VALID_EVENT_ID, recommendation);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:recommendation:" + VALID_EVENT_ID, recommendation, 86400L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getRecommendation() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing recommendation, When get, Then return recommendation")
|
||||
void givenExistingRecommendation_whenGet_thenReturnRecommendation() {
|
||||
// Given
|
||||
Object expectedRecommendation = "recommendation-data";
|
||||
when(valueOperations.get("ai:recommendation:" + VALID_EVENT_ID))
|
||||
.thenReturn(expectedRecommendation);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getRecommendation(VALID_EVENT_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedRecommendation);
|
||||
verify(valueOperations, times(1)).get("ai:recommendation:" + VALID_EVENT_ID);
|
||||
}
|
||||
|
||||
// ========== saveTrend() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given valid trend, When save, Then success")
|
||||
void givenValidTrend_whenSave_thenSuccess() {
|
||||
// Given
|
||||
Object trend = "trend-data";
|
||||
doNothing().when(valueOperations).set(anyString(), any(), anyLong(), any(TimeUnit.class));
|
||||
|
||||
// When
|
||||
cacheService.saveTrend(VALID_INDUSTRY, VALID_REGION, trend);
|
||||
|
||||
// Then
|
||||
verify(valueOperations, times(1))
|
||||
.set("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION, trend, 3600L, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
// ========== getTrend() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing trend, When get, Then return trend")
|
||||
void givenExistingTrend_whenGet_thenReturnTrend() {
|
||||
// Given
|
||||
Object expectedTrend = "trend-data";
|
||||
when(valueOperations.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION))
|
||||
.thenReturn(expectedTrend);
|
||||
|
||||
// When
|
||||
Object result = cacheService.getTrend(VALID_INDUSTRY, VALID_REGION);
|
||||
|
||||
// Then
|
||||
assertThat(result).isEqualTo(expectedTrend);
|
||||
verify(valueOperations, times(1))
|
||||
.get("ai:trend:" + VALID_INDUSTRY + ":" + VALID_REGION);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package com.kt.ai.test.unit.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.kt.ai.exception.JobNotFoundException;
|
||||
import com.kt.ai.model.dto.response.JobStatusResponse;
|
||||
import com.kt.ai.model.enums.JobStatus;
|
||||
import com.kt.ai.service.CacheService;
|
||||
import com.kt.ai.service.JobStatusService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* JobStatusService 단위 테스트
|
||||
*
|
||||
* @author AI Service Team
|
||||
* @since 1.0.0
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@DisplayName("JobStatusService 단위 테스트")
|
||||
class JobStatusServiceUnitTest {
|
||||
|
||||
// Constants
|
||||
private static final String VALID_JOB_ID = "job-123";
|
||||
private static final String INVALID_JOB_ID = "job-999";
|
||||
private static final String VALID_MESSAGE = "AI 추천 생성 중";
|
||||
|
||||
@Mock
|
||||
private CacheService cacheService;
|
||||
|
||||
@Mock
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@InjectMocks
|
||||
private JobStatusService jobStatusService;
|
||||
|
||||
private JobStatusResponse sampleJobStatusResponse;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
sampleJobStatusResponse = JobStatusResponse.builder()
|
||||
.jobId(VALID_JOB_ID)
|
||||
.status(JobStatus.PROCESSING)
|
||||
.progress(50)
|
||||
.message(VALID_MESSAGE)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ========== getJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given existing job, When get status, Then return job status")
|
||||
void givenExistingJob_whenGetStatus_thenReturnJobStatus() {
|
||||
// Given
|
||||
Map<String, Object> cachedData = createCachedJobStatusData();
|
||||
when(cacheService.getJobStatus(VALID_JOB_ID)).thenReturn(cachedData);
|
||||
when(objectMapper.convertValue(cachedData, JobStatusResponse.class))
|
||||
.thenReturn(sampleJobStatusResponse);
|
||||
|
||||
// When
|
||||
JobStatusResponse result = jobStatusService.getJobStatus(VALID_JOB_ID);
|
||||
|
||||
// Then
|
||||
assertThat(result).isNotNull();
|
||||
assertThat(result.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(result.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||
assertThat(result.getProgress()).isEqualTo(50);
|
||||
assertThat(result.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||
|
||||
verify(cacheService, times(1)).getJobStatus(VALID_JOB_ID);
|
||||
verify(objectMapper, times(1)).convertValue(cachedData, JobStatusResponse.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given non-existing job, When get status, Then throw JobNotFoundException")
|
||||
void givenNonExistingJob_whenGetStatus_thenThrowJobNotFoundException() {
|
||||
// Given
|
||||
when(cacheService.getJobStatus(INVALID_JOB_ID)).thenReturn(null);
|
||||
|
||||
// When & Then
|
||||
assertThatThrownBy(() -> jobStatusService.getJobStatus(INVALID_JOB_ID))
|
||||
.isInstanceOf(JobNotFoundException.class)
|
||||
.hasMessageContaining(INVALID_JOB_ID);
|
||||
|
||||
verify(cacheService, times(1)).getJobStatus(INVALID_JOB_ID);
|
||||
verify(objectMapper, never()).convertValue(any(), eq(JobStatusResponse.class));
|
||||
}
|
||||
|
||||
// ========== updateJobStatus() 메서드 테스트 ==========
|
||||
|
||||
@Test
|
||||
@DisplayName("Given PENDING status, When update, Then save with 0% progress")
|
||||
void givenPendingStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PENDING, "대기 중");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.PENDING);
|
||||
assertThat(saved.getProgress()).isEqualTo(0);
|
||||
assertThat(saved.getMessage()).isEqualTo("대기 중");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given PROCESSING status, When update, Then save with 50% progress")
|
||||
void givenProcessingStatus_whenUpdate_thenSaveWithFiftyProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.PROCESSING, VALID_MESSAGE);
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.PROCESSING);
|
||||
assertThat(saved.getProgress()).isEqualTo(50);
|
||||
assertThat(saved.getMessage()).isEqualTo(VALID_MESSAGE);
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given COMPLETED status, When update, Then save with 100% progress")
|
||||
void givenCompletedStatus_whenUpdate_thenSaveWithHundredProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.COMPLETED, "AI 추천 완료");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.COMPLETED);
|
||||
assertThat(saved.getProgress()).isEqualTo(100);
|
||||
assertThat(saved.getMessage()).isEqualTo("AI 추천 완료");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Given FAILED status, When update, Then save with 0% progress")
|
||||
void givenFailedStatus_whenUpdate_thenSaveWithZeroProgress() {
|
||||
// Given
|
||||
doNothing().when(cacheService).saveJobStatus(eq(VALID_JOB_ID), any(JobStatusResponse.class));
|
||||
|
||||
// When
|
||||
jobStatusService.updateJobStatus(VALID_JOB_ID, JobStatus.FAILED, "AI API 호출 실패");
|
||||
|
||||
// Then
|
||||
ArgumentCaptor<JobStatusResponse> captor = ArgumentCaptor.forClass(JobStatusResponse.class);
|
||||
verify(cacheService, times(1)).saveJobStatus(eq(VALID_JOB_ID), captor.capture());
|
||||
|
||||
JobStatusResponse saved = captor.getValue();
|
||||
assertThat(saved.getJobId()).isEqualTo(VALID_JOB_ID);
|
||||
assertThat(saved.getStatus()).isEqualTo(JobStatus.FAILED);
|
||||
assertThat(saved.getProgress()).isEqualTo(0);
|
||||
assertThat(saved.getMessage()).isEqualTo("AI API 호출 실패");
|
||||
assertThat(saved.getCreatedAt()).isNotNull();
|
||||
}
|
||||
|
||||
// ========== Helper Methods ==========
|
||||
|
||||
/**
|
||||
* Cache에 저장된 Job 상태 데이터 생성 (LinkedHashMap 형태)
|
||||
*/
|
||||
private Map<String, Object> createCachedJobStatusData() {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("jobId", VALID_JOB_ID);
|
||||
data.put("status", JobStatus.PROCESSING.name());
|
||||
data.put("progress", 50);
|
||||
data.put("message", VALID_MESSAGE);
|
||||
data.put("createdAt", LocalDateTime.now().toString());
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
spring:
|
||||
application:
|
||||
name: ai-service-test
|
||||
|
||||
# Redis Configuration (테스트용)
|
||||
data:
|
||||
redis:
|
||||
host: ${REDIS_HOST:20.214.210.71}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:Hi5Jessica!}
|
||||
database: ${REDIS_DATABASE:3}
|
||||
timeout: 3000
|
||||
|
||||
# Kafka Configuration (테스트용)
|
||||
kafka:
|
||||
bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:20.249.182.13:9095,4.217.131.59:9095}
|
||||
consumer:
|
||||
group-id: ai-service-test-consumers
|
||||
auto-offset-reset: earliest
|
||||
enable-auto-commit: false
|
||||
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
|
||||
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
listener:
|
||||
ack-mode: manual
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: 0 # 랜덤 포트 사용
|
||||
|
||||
# JWT Configuration (테스트용)
|
||||
jwt:
|
||||
secret: test-jwt-secret-key-for-testing-only
|
||||
access-token-validity: 1800
|
||||
refresh-token-validity: 86400
|
||||
|
||||
# Kafka Topics
|
||||
kafka:
|
||||
topics:
|
||||
ai-job: ai-event-generation-job
|
||||
ai-job-dlq: ai-event-generation-job-dlq
|
||||
|
||||
# AI API Configuration (테스트용 - Mock 사용)
|
||||
ai:
|
||||
provider: CLAUDE
|
||||
claude:
|
||||
api-url: ${CLAUDE_API_URL:https://api.anthropic.com/v1/messages}
|
||||
api-key: ${CLAUDE_API_KEY:test-key}
|
||||
anthropic-version: 2023-06-01
|
||||
model: claude-3-5-sonnet-20241022
|
||||
max-tokens: 4096
|
||||
temperature: 0.7
|
||||
timeout: 300000
|
||||
|
||||
# Cache TTL
|
||||
cache:
|
||||
ttl:
|
||||
recommendation: 86400
|
||||
job-status: 86400
|
||||
trend: 3600
|
||||
fallback: 604800
|
||||
|
||||
# Logging
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
com.kt.ai: DEBUG
|
||||
org.springframework.kafka: DEBUG
|
||||
Reference in New Issue
Block a user