From 551fc469f2156981896e401127069a6be256c136 Mon Sep 17 00:00:00 2001 From: config25 Date: Tue, 7 Apr 2026 14:57:16 +0900 Subject: [PATCH] =?UTF-8?q?test:=20AI=20=ED=8C=8C=EC=9D=B4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR1(책임 분리)·PR2(@ConfigurationProperties) 리팩토링의 효과를 직접 검증하는 단위 테스트 13개 추가. 도메인/외부 모듈 모두 첫 단위 테스트 도입. 신규 테스트: - MaskGeneratorTest (demo-domain, 5건) - README "LAMA 인페인팅 가장자리 잔상" 트러블슈팅 직접 검증 - 박스 20px 확장, 좌상/우하 경계 클램핑, 빈 입력, 다중 박스 - GptVisionClientTest (demo-external, 5건) - PR2의 record properties 효과 — Spring 컨텍스트 없이 new OpenAiProperties(...)로 주입 - MockRestServiceServer로 OpenAI 호출 모킹 - 200 OK 파싱, 429 → AI_RATE_LIMIT_EXCEEDED, 빈 choices → AI_PROMPT_FAILED - 비교 분석의 429 일관 처리 (PR1 사일런트 버그 수정 검증) - AiProcessorTest (demo-domain, 3건) - PR1의 DIP 효과 — ExternalAiClient 인터페이스만 mock으로 흐름 검증 - 빈 boxes → AI_DETECTION_EMPTY + LAMA 미호출 보장 - 정상 흐름 → 마스크 생성 후 LAMA 호출 + URL 반환 - 분석 결과 저장 verify 검증: - ./gradlew test 통과 — 13/13 PASS, 0 FAIL - 의존성 추가 없음 (spring-boot-starter-test 기존 보유) - 외부 인프라 호출 0 (모두 mock) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../implementation/ai/AiProcessorTest.java | 102 ++++++++++++ .../demo/model/ai/mask/MaskGeneratorTest.java | 128 +++++++++++++++ .../demo/external/ai/GptVisionClientTest.java | 147 ++++++++++++++++++ 3 files changed, 377 insertions(+) create mode 100644 demo-domain/src/test/java/team9/demo/implementation/ai/AiProcessorTest.java create mode 100644 demo-domain/src/test/java/team9/demo/model/ai/mask/MaskGeneratorTest.java create mode 100644 demo-external/src/test/java/team9/demo/external/ai/GptVisionClientTest.java diff --git a/demo-domain/src/test/java/team9/demo/implementation/ai/AiProcessorTest.java b/demo-domain/src/test/java/team9/demo/implementation/ai/AiProcessorTest.java new file mode 100644 index 0000000..c6c33c7 --- /dev/null +++ b/demo-domain/src/test/java/team9/demo/implementation/ai/AiProcessorTest.java @@ -0,0 +1,102 @@ +package team9.demo.implementation.ai; + +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 team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.model.ai.mask.Box; +import team9.demo.model.media.FileData; +import team9.demo.model.user.UserId; +import team9.demo.repository.ai.ExternalAiClient; +import team9.demo.service.user.AiAnalysisGenerator; + +import java.util.Collections; +import java.util.List; + +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link AiProcessor}의 오케스트레이션 동작 검증. + *

+ * PR1(책임 분리)에서 도메인이 {@code ExternalAiClient} 인터페이스에만 의존하도록 만든 효과를 + * 직접 보여주는 테스트 — 실제 GPT/YOLO/LAMA 호출 없이 mock으로 흐름만 검증한다. + */ +@ExtendWith(MockitoExtension.class) +class AiProcessorTest { + + @Mock + private ExternalAiClient externalAiClient; + + @Mock + private AiAnalysisGenerator aiAnalysisGenerator; + + @Mock + private AiPromptGenerator aiPromptGenerator; + + @InjectMocks + private AiProcessor aiProcessor; + + private final UserId userId = UserId.of("user-1"); + + @Test + @DisplayName("YOLO가 빈 박스 리스트를 반환하면 LAMA를 호출하지 않고 AI_DETECTION_EMPTY를 던진다") + void cleanImageWithLama_emptyBoxes_throwsDetectionEmpty() { + FileData image = FileData.of(null, null, "room.jpg", 1024L, new byte[]{1, 2, 3}, 800, 600); + when(externalAiClient.detectClutterBoxes(any())).thenReturn(Collections.emptyList()); + + assertThatThrownBy(() -> aiProcessor.cleanImageWithLama(image, 800, 600, userId)) + .isInstanceOf(AiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.AI_DETECTION_EMPTY); + + // LAMA는 절대 호출되지 않아야 함 — 빈 마스크로 LAMA를 때리는 낭비/오류 방지 + verify(externalAiClient, never()).editImageWithLama(any(), any(), anyString(), any()); + } + + @Test + @DisplayName("YOLO가 박스를 반환하면 마스크를 만들어 LAMA를 호출하고 결과 URL을 반환한다") + void cleanImageWithLama_normalFlow_returnsCleanedUrl() { + FileData image = FileData.of(null, null, "room.jpg", 1024L, new byte[]{1, 2, 3}, 200, 200); + List boxes = List.of(new Box(50, 50, 60, 60)); + + when(externalAiClient.detectClutterBoxes(any())).thenReturn(boxes); + when(aiPromptGenerator.lamaInpainting()).thenReturn("clean prompt"); + when(externalAiClient.editImageWithLama(any(), any(), eq("clean prompt"), eq(userId))) + .thenReturn("https://s3/cleaned/result.png"); + + String resultUrl = aiProcessor.cleanImageWithLama(image, 200, 200, userId); + + assertThat(resultUrl).isEqualTo("https://s3/cleaned/result.png"); + verify(externalAiClient).detectClutterBoxes(image.getContent()); + verify(externalAiClient).editImageWithLama(any(), any(), eq("clean prompt"), eq(userId)); + } + + @Test + @DisplayName("이미지 분석 결과는 외부 클라이언트 응답을 그대로 반환하면서 분석 결과를 저장한다") + void requestImageAnalysis_savesAnalysisAndReturnsResponse() { + team9.demo.model.ai.analysis.ChatResponse mockResponse = new team9.demo.model.ai.analysis.ChatResponse( + List.of(new team9.demo.model.ai.analysis.Choice(0, + new team9.demo.model.ai.analysis.TextMessage("assistant", "정리 가이드 내용"))) + ); + when(externalAiClient.requestImageAnalysis("https://img/url", "정리법")) + .thenReturn(mockResponse); + + team9.demo.model.ai.analysis.ChatResponse result = + aiProcessor.requestImageAnalysis("https://img/url", "정리법", userId); + + assertThat(result.getResultMessage()).isEqualTo("정리 가이드 내용"); + verify(aiAnalysisGenerator).saveAnalysisResult(userId, "정리 가이드 내용", "https://img/url"); + } +} diff --git a/demo-domain/src/test/java/team9/demo/model/ai/mask/MaskGeneratorTest.java b/demo-domain/src/test/java/team9/demo/model/ai/mask/MaskGeneratorTest.java new file mode 100644 index 0000000..1baa545 --- /dev/null +++ b/demo-domain/src/test/java/team9/demo/model/ai/mask/MaskGeneratorTest.java @@ -0,0 +1,128 @@ +package team9.demo.model.ai.mask; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link MaskGenerator}의 마스크 후처리 동작 검증. + *

+ * YOLO bounding box를 그대로 사용하면 LAMA 인페인팅 결과에 가장자리 잔상이 남는 문제를 해결하기 위해 + * 1) 박스를 20px 확장하고 + * 2) 라운드 처리 + AntiAliasing을 적용했다. + *

+ * 본 테스트는 README "Troubleshooting > LAMA 인페인팅 가장자리 잔상" 항목의 + * 후처리 로직이 의도대로 동작하는지를 보장한다. + */ +class MaskGeneratorTest { + + private static final int EXPAND = 20; + private static final int CANVAS_WIDTH = 200; + private static final int CANVAS_HEIGHT = 200; + + private BufferedImage decode(byte[] pngBytes) throws IOException { + try (ByteArrayInputStream in = new ByteArrayInputStream(pngBytes)) { + return ImageIO.read(in); + } + } + + /** 그레이스케일 마스크에서 (x,y)가 흰색(=마스킹 영역)인지 확인. */ + private boolean isMasked(BufferedImage mask, int x, int y) { + // BufferedImage.TYPE_BYTE_GRAY 는 getRGB의 R/G/B가 모두 동일한 밝기 값 + return new Color(mask.getRGB(x, y)).getRed() > 200; + } + + @Test + @DisplayName("빈 박스 리스트도 안전하게 처리되어 전부 검은(미마스킹) 그레이 PNG를 반환한다") + void createMask_emptyBoxes_returnsBlankMask() throws IOException { + byte[] result = MaskGenerator.createMask(Collections.emptyList(), CANVAS_WIDTH, CANVAS_HEIGHT); + + BufferedImage mask = decode(result); + assertThat(mask).isNotNull(); + assertThat(mask.getWidth()).isEqualTo(CANVAS_WIDTH); + assertThat(mask.getHeight()).isEqualTo(CANVAS_HEIGHT); + assertThat(isMasked(mask, 100, 100)).isFalse(); + } + + @Nested + @DisplayName("박스 확장(EXPAND=20px)") + class BoxExpansion { + + @Test + @DisplayName("박스 외곽 EXPAND 픽셀 안쪽까지 마스킹 영역에 포함된다") + void expansion_extendsOutsideOriginalBox() throws IOException { + // 캔버스 중앙에 충분히 안쪽으로 떨어진 박스 — 경계 클램핑 영향을 배제 + Box box = new Box(80, 80, 40, 40); // (80,80) ~ (120,120) + byte[] result = MaskGenerator.createMask(List.of(box), CANVAS_WIDTH, CANVAS_HEIGHT); + + BufferedImage mask = decode(result); + + // 원본 박스 내부는 당연히 마스킹 + assertThat(isMasked(mask, 100, 100)).isTrue(); + + // 원본 박스 바로 바깥(왼쪽으로 EXPAND-5=15px)도 확장 영역에 포함 + assertThat(isMasked(mask, 80 - 15, 100)).isTrue(); + + // 확장 한계 바깥(왼쪽으로 EXPAND+10=30px)은 마스킹되지 않아야 함 + // (라운드 코너 영향을 피하기 위해 y는 박스 중앙에서 확인) + assertThat(isMasked(mask, 80 - (EXPAND + 10), 100)).isFalse(); + } + } + + @Nested + @DisplayName("경계 클램핑") + class BoundaryClamping { + + @Test + @DisplayName("좌상단(0,0)에 붙은 박스를 확장해도 음수 좌표로 나가지 않는다") + void clamping_topLeftCorner_doesNotOverflowNegative() throws IOException { + Box box = new Box(0, 0, 30, 30); + + // 예외 없이 정상적으로 PNG가 생성되어야 함 (음수 좌표로 fillRoundRect 호출 시 IllegalArgumentException 위험 차단) + byte[] result = MaskGenerator.createMask(List.of(box), CANVAS_WIDTH, CANVAS_HEIGHT); + + BufferedImage mask = decode(result); + assertThat(mask).isNotNull(); + // 좌상단 모서리는 라운드 처리로 살짝 깎일 수 있어 박스 내부 중앙 픽셀로 검증 + assertThat(isMasked(mask, 15, 15)).isTrue(); + } + + @Test + @DisplayName("우하단 경계에 붙은 박스를 확장해도 캔버스 크기를 넘지 않는다") + void clamping_bottomRightCorner_doesNotOverflowCanvas() throws IOException { + Box box = new Box(170, 170, 30, 30); // 우하단까지 꽉 차는 박스 + + // 예외 없이 정상 생성 — 확장 후에도 width/height를 초과하지 않아야 함 + byte[] result = MaskGenerator.createMask(List.of(box), CANVAS_WIDTH, CANVAS_HEIGHT); + + BufferedImage mask = decode(result); + assertThat(mask.getWidth()).isEqualTo(CANVAS_WIDTH); + assertThat(mask.getHeight()).isEqualTo(CANVAS_HEIGHT); + assertThat(isMasked(mask, 185, 185)).isTrue(); + } + } + + @Test + @DisplayName("여러 박스가 모두 마스킹 영역에 반영된다") + void createMask_multipleBoxes_allRendered() throws IOException { + Box box1 = new Box(40, 40, 30, 30); + Box box2 = new Box(140, 140, 30, 30); + + byte[] result = MaskGenerator.createMask(List.of(box1, box2), CANVAS_WIDTH, CANVAS_HEIGHT); + + BufferedImage mask = decode(result); + assertThat(isMasked(mask, 55, 55)).isTrue(); // box1 내부 + assertThat(isMasked(mask, 155, 155)).isTrue(); // box2 내부 + assertThat(isMasked(mask, 100, 100)).isFalse(); // 두 박스 사이 간격 + } +} diff --git a/demo-external/src/test/java/team9/demo/external/ai/GptVisionClientTest.java b/demo-external/src/test/java/team9/demo/external/ai/GptVisionClientTest.java new file mode 100644 index 0000000..5106be8 --- /dev/null +++ b/demo-external/src/test/java/team9/demo/external/ai/GptVisionClientTest.java @@ -0,0 +1,147 @@ +package team9.demo.external.ai; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; +import team9.demo.error.AiException; +import team9.demo.error.ErrorCode; +import team9.demo.external.config.properties.OpenAiProperties; +import team9.demo.model.ai.analysis.ChatResponse; + +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * {@link GptVisionClient}의 OpenAI 호출 동작 검증. + *

+ * 이 테스트는 PR2(@Value → @ConfigurationProperties)의 효과를 직접 보여준다 — + * Spring 컨텍스트 없이 record properties 인스턴스를 그냥 new 해서 주입하면 끝. + *

+ * RestTemplate은 Spring Boot 내장 {@link MockRestServiceServer}로 모킹하여 + * 실제 OpenAI 호출 없이 응답 시나리오별 동작을 검증한다. + */ +class GptVisionClientTest { + + private static final String CHAT_ENDPOINT = "https://api.openai.test/v1/chat/completions"; + private static final String IMAGE_URL = "https://bucket.s3.amazonaws.com/test.jpg"; + + private RestTemplate restTemplate; + private MockRestServiceServer mockServer; + private S3ImageStore s3ImageStore; + private GptVisionClient client; + + @BeforeEach + void setUp() throws IOException { + restTemplate = new RestTemplate(); + mockServer = MockRestServiceServer.createServer(restTemplate); + + // S3 다운로드 부분만 모킹 — 실제 S3 호출 제거 + s3ImageStore = mock(S3ImageStore.class); + when(s3ImageStore.encodeBase64(anyString())).thenReturn("BASE64_DUMMY"); + + // ⭐ PR2의 효과: Spring 컨텍스트 없이 record 인스턴스만으로 주입 가능 + OpenAiProperties properties = new OpenAiProperties("test-key", CHAT_ENDPOINT, "gpt-4-vision-preview"); + + client = new GptVisionClient(restTemplate, s3ImageStore, properties); + } + + @Test + @DisplayName("정상 응답: choices[0].message.content를 ChatResponse로 파싱한다") + void requestImageAnalysis_success() { + String body = """ + { + "choices": [ + { + "message": { + "role": "assistant", + "content": "방을 정리하려면 책상 위 물건부터 분류하세요." + } + } + ] + } + """; + mockServer.expect(requestTo(CHAT_ENDPOINT)) + .andRespond(withSuccess(body, MediaType.APPLICATION_JSON)); + + ChatResponse response = client.requestImageAnalysis(IMAGE_URL, "정리법 알려줘"); + + assertThat(response).isNotNull(); + assertThat(response.getResultMessage()).contains("책상 위 물건부터 분류"); + mockServer.verify(); + } + + @Test + @DisplayName("OpenAI 429 응답은 AI_RATE_LIMIT_EXCEEDED 도메인 예외로 래핑된다") + void requestImageAnalysis_tooManyRequests_wrappedToRateLimitError() { + mockServer.expect(requestTo(CHAT_ENDPOINT)) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS) + .body("{\"error\":{\"message\":\"rate limit\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.requestImageAnalysis(IMAGE_URL, "test")) + .isInstanceOf(AiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.AI_RATE_LIMIT_EXCEEDED); + } + + @Test + @DisplayName("choices가 비어있는 응답은 AI_PROMPT_FAILED로 처리된다") + void requestImageAnalysis_emptyChoices_throwsPromptFailed() { + mockServer.expect(requestTo(CHAT_ENDPOINT)) + .andRespond(withSuccess("{\"choices\": []}", MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.requestImageAnalysis(IMAGE_URL, "test")) + .isInstanceOf(AiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.AI_PROMPT_FAILED); + } + + @Test + @DisplayName("비교 분석(before/after)도 정상 응답을 ChatResponse로 파싱한다") + void requestCompareAnalysis_success() throws IOException { + when(s3ImageStore.encodeBase64("before.jpg")).thenReturn("BEFORE_B64"); + when(s3ImageStore.encodeBase64("after.jpg")).thenReturn("AFTER_B64"); + + mockServer.expect(requestTo(CHAT_ENDPOINT)) + .andRespond(withSuccess(""" + { + "choices": [ + { "message": { "role": "assistant", "content": "[RESULT:SUCCESS]" } } + ] + } + """, MediaType.APPLICATION_JSON)); + + ChatResponse response = client.requestCompareAnalysis("before.jpg", "after.jpg", "비교해줘"); + + assertThat(response.getResultMessage()).contains("[RESULT:SUCCESS]"); + mockServer.verify(); + } + + @Test + @DisplayName("비교 분석에서 OpenAI 429도 AI_RATE_LIMIT_EXCEEDED로 일관 처리된다 (PR1 사일런트 버그 수정 검증)") + void requestCompareAnalysis_tooManyRequests_wrappedToRateLimitError() throws IOException { + when(s3ImageStore.encodeBase64(anyString())).thenReturn("B64"); + + mockServer.expect(requestTo(CHAT_ENDPOINT)) + .andRespond(withStatus(HttpStatus.TOO_MANY_REQUESTS) + .body("{}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.requestCompareAnalysis("a.jpg", "b.jpg", "test")) + .isInstanceOf(AiException.class) + .extracting("errorCode") + .isEqualTo(ErrorCode.AI_RATE_LIMIT_EXCEEDED); + } +}