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:
박세원
2025-10-27 16:27:14 +09:00
parent f0699b2e2b
commit 29dddd89b7
50 changed files with 2492 additions and 47 deletions
@@ -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();
}
}
@@ -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