Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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}의 오케스트레이션 동작 검증.
* <p>
* 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<Box> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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}의 마스크 후처리 동작 검증.
* <p>
* YOLO bounding box를 그대로 사용하면 LAMA 인페인팅 결과에 가장자리 잔상이 남는 문제를 해결하기 위해
* 1) 박스를 20px 확장하고
* 2) 라운드 처리 + AntiAliasing을 적용했다.
* <p>
* 본 테스트는 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(); // 두 박스 사이 간격
}
}
Original file line number Diff line number Diff line change
@@ -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 호출 동작 검증.
* <p>
* 이 테스트는 PR2(@Value → @ConfigurationProperties)의 효과를 직접 보여준다 —
* Spring 컨텍스트 없이 record properties 인스턴스를 그냥 new 해서 주입하면 끝.
* <p>
* 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);
}
}
Loading