Skip to content
Open
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
4 changes: 4 additions & 0 deletions clokey-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ dependencies {
implementation 'io.vanslog:spring-data-meilisearch:0.7.3'

testImplementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0'

testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.awaitility:awaitility:4.3.0'
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

@Repository
@RequiredArgsConstructor
@Profile("!test")
@Profile({"!test", "meilisearch-it"})
public class MeiliSearchRepositoryImpl implements SearchRepository {

private static final String HISTORY_INDEX = "histories";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public Slice<ClothListResponse> findClothesByKeyword(
cloth.clothImageUrl,
cloth.brand,
cloth.name,
cloth.category.parent.name,
cloth.category.name))
.from(cloth)
.where(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ private List<SearchingRecommendResponse> computeAndCacheRecommendations(

if (!untriedStyleIds.isEmpty()) {
searchRecommendRepository
.findBestHistoryForUntriedStyle(excludedMemberIds, untriedStyleIds)
.findBestHistoryForUntriedStyle(excludedMemberIds, userUsedStyleIds)
.ifPresent(
row ->
results.add(
Expand Down
19 changes: 14 additions & 5 deletions clokey-api/src/test/java/org/clokey/DatabaseCleaner.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.Session;
import org.springframework.beans.factory.InitializingBean;
Expand All @@ -23,11 +24,19 @@ public void afterPropertiesSet() {
entityManager.unwrap(Session.class).doWork(this::extractTableNames);
}

private void extractTableNames(Connection conn) {
tableNames =
entityManager.getMetamodel().getEntities().stream()
.map(e -> e.getName().replaceAll("([a-z])([A-Z])", "$1_$2").toLowerCase())
.toList();
/**
* JPA 엔티티 메타모델만 사용하면 {@code @ElementCollection} 조인 테이블(예: cloth_season)처럼 별도 엔티티가 아닌 테이블은 누락되어
* 테스트 간 데이터가 leak 된다. 실제 DB에 존재하는 모든 테이블을 기준으로 truncate 대상을 결정한다.
*/
private void extractTableNames(Connection conn) throws SQLException {
List<String> names = new ArrayList<>();
try (ResultSet rs =
conn.getMetaData().getTables(null, "PUBLIC", "%", new String[] {"TABLE"})) {
while (rs.next()) {
names.add(rs.getString("TABLE_NAME"));
}
}
tableNames = names;
}

public void execute() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package org.clokey;

import com.google.firebase.messaging.FirebaseMessaging;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

/**
* 실제 Meilisearch 컨테이너를 띄워 검색엔진 연동 로직(인덱싱, 검색)을 검증하기 위한 베이스 클래스입니다. "test" 프로파일에서는 {@link
* org.clokey.domain.search.repository.NoopSearchRepository} 가 검색 리포지토리를 대체하므로, 실제 Meilisearch 연동을
* 검증하려면 "meilisearch-it" 프로파일을 함께 활성화해 {@link
* org.clokey.domain.search.repository.MeiliSearchRepositoryImpl} 이 사용되도록 해야 합니다.
*/
@SpringBootTest
@ActiveProfiles({"test", "meilisearch-it"})
@Testcontainers
public abstract class MeiliSearchIntegrationTest {

private static final String MEILISEARCH_MASTER_KEY = "test-master-key-must-be-16-bytes-or-more";

@org.testcontainers.junit.jupiter.Container
static final GenericContainer<?> MEILISEARCH_CONTAINER =
new GenericContainer<>(DockerImageName.parse("getmeili/meilisearch:v1.15"))
.withExposedPorts(7700)
.withEnv("MEILI_MASTER_KEY", MEILISEARCH_MASTER_KEY)
.withEnv("MEILI_NO_ANALYTICS", "true")
.waitingFor(Wait.forHttp("/health").forStatusCode(200));

@DynamicPropertySource
static void meilisearchProperties(DynamicPropertyRegistry registry) {
registry.add(
"spring.data.meilisearch.url",
() ->
"http://"
+ MEILISEARCH_CONTAINER.getHost()
+ ":"
+ MEILISEARCH_CONTAINER.getMappedPort(7700));
registry.add("spring.data.meilisearch.api-key", () -> MEILISEARCH_MASTER_KEY);
}

@Autowired protected DatabaseCleaner databaseCleaner;
@MockitoBean private FirebaseMessaging mockFirebaseMessaging;
@MockitoBean private ClientRegistrationRepository clientRegistrationRepository;

@BeforeEach
void setUpMeiliSearchIntegrationTest() {
databaseCleaner.execute();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
import org.clokey.cloth.enums.Season;
import org.clokey.domain.cloth.dto.request.ClothCreateRequest;
import org.clokey.domain.cloth.dto.request.ClothCreateRequests;
import org.clokey.domain.cloth.dto.request.ClothDetectRequest;
import org.clokey.domain.cloth.dto.request.ClothImagesUploadRequest;
import org.clokey.domain.cloth.dto.request.ClothInfoExtractRequest;
import org.clokey.domain.cloth.dto.request.ClothUpdateRequest;
import org.clokey.domain.cloth.dto.request.HistoryStyleInferenceRequest;
import org.clokey.domain.cloth.dto.response.*;
import org.clokey.domain.cloth.service.ClothAiService;
import org.clokey.domain.cloth.service.ClothService;
Expand Down Expand Up @@ -80,6 +83,190 @@ class 옷_업로드_presigned_url_발급_요청_시 {
}
}

@Nested
class 옷_정보_추출_요청_시 {

@Test
void 유효한_요청이면_옷_정보를_반환한다() throws Exception {
// given
ClothInfoExtractRequest request =
new ClothInfoExtractRequest(List.of("testClothImageUrl1"));

ClothInfoExtractResponse response =
ClothInfoExtractResponse.of(
List.of(
new ClothInfoExtractResponse.Payload(
"testClothImageUrl1",
List.of(Season.SPRING),
1L,
"testParentCategory",
2L,
"testCategory")));

given(clothAiService.extractClothInfo(request)).willReturn(response);

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/extract")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.code").value("COMMON201"))
.andExpect(
jsonPath("$.result.payloads[0].clothImageUrl")
.value("testClothImageUrl1"))
.andExpect(jsonPath("$.result.payloads[0].categoryId").value(2))
.andExpect(jsonPath("$.result.payloads[0].categoryName").value("testCategory"));
}

@Test
void 옷_이미지_URL_목록이_비어있으면_예외가_발생한다() throws Exception {
// given
ClothInfoExtractRequest request = new ClothInfoExtractRequest(List.of());

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/extract")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false))
.andExpect(jsonPath("$.code").value("COMMON400"))
.andExpect(
jsonPath("$.result.clothImageUrls").value("옷 이미지 URL 목록은 비워둘 수 없습니다."));
}

@Test
void 옷_이미지_URL이_11개를_초과하면_예외가_발생한다() throws Exception {
// given
ClothInfoExtractRequest request =
new ClothInfoExtractRequest(
List.of(
"url1", "url2", "url3", "url4", "url5", "url6", "url7", "url8",
"url9", "url10", "url11"));

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/extract")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false))
.andExpect(jsonPath("$.code").value("COMMON400"));
}
}

@Nested
class 기록_사진_스타일_추론_요청_시 {

@Test
void 유효한_요청이면_스타일을_반환한다() throws Exception {
// given
HistoryStyleInferenceRequest request =
new HistoryStyleInferenceRequest("testHistoryImageUrl");

HistoryStyleInferenceResponse response =
HistoryStyleInferenceResponse.of(
1L,
"testSituation",
List.of(
new HistoryStyleInferenceResponse.StylePayload(
2L, "testStyle")));

given(clothAiService.inferHistoryStyle(request)).willReturn(response);

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/history-style")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.code").value("COMMON201"))
.andExpect(jsonPath("$.result.situationId").value(1))
.andExpect(jsonPath("$.result.situationName").value("testSituation"))
.andExpect(jsonPath("$.result.styles[0].styleId").value(2));
}

@ParameterizedTest
@NullSource
@EmptySource
void 기록_이미지_URL이_없으면_예외가_발생한다(String historyImageUrl) throws Exception {
// given
HistoryStyleInferenceRequest request =
new HistoryStyleInferenceRequest(historyImageUrl);

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/history-style")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false))
.andExpect(jsonPath("$.code").value("COMMON400"));
}
}

@Nested
class 사진_옷_탐지_요청_시 {

@Test
void 유효한_요청이면_탐지된_옷을_반환한다() throws Exception {
// given
ClothDetectRequest request = new ClothDetectRequest("testImageUrl");

ClothDetectResponse response =
ClothDetectResponse.of(
List.of(new ClothDetectResponse.Payload("testUploadedUrl1")));

given(clothAiService.detectClothes(request)).willReturn(response);

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/detect")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.code").value("COMMON201"))
.andExpect(
jsonPath("$.result.payloads[0].clothImageUrl")
.value("testUploadedUrl1"));
}

@ParameterizedTest
@NullSource
@EmptySource
void 이미지_URL이_없으면_예외가_발생한다(String imageUrl) throws Exception {
// given
ClothDetectRequest request = new ClothDetectRequest(imageUrl);

// when & then
ResultActions perform =
mockMvc.perform(
post("/cloth-ai/detect")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)));

perform.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.isSuccess").value(false))
.andExpect(jsonPath("$.code").value("COMMON400"));
}
}

@Nested
class 옷_생성_요청_시 {

Expand Down
Loading
Loading