From b45dcf7aaff7cac7a45246c8472b6b77861142a7 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 5 Aug 2026 22:26:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix=20:=20application-test.yml=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EB=88=84=EB=9D=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=EC=B2=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EB=94=A9=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/java/org/clokey/DatabaseCleaner.java | 19 +++++-- .../SwaggerSecurityIntegrationTest.java | 29 +--------- .../src/test/resources/application-test.yml | 56 +++++++++++++++++++ 3 files changed, 71 insertions(+), 33 deletions(-) diff --git a/clokey-api/src/test/java/org/clokey/DatabaseCleaner.java b/clokey-api/src/test/java/org/clokey/DatabaseCleaner.java index 31021f1a..c7312538 100644 --- a/clokey-api/src/test/java/org/clokey/DatabaseCleaner.java +++ b/clokey-api/src/test/java/org/clokey/DatabaseCleaner.java @@ -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; @@ -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 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() { diff --git a/clokey-api/src/test/java/org/clokey/global/config/security/SwaggerSecurityIntegrationTest.java b/clokey-api/src/test/java/org/clokey/global/config/security/SwaggerSecurityIntegrationTest.java index 411f24f5..76ca9001 100644 --- a/clokey-api/src/test/java/org/clokey/global/config/security/SwaggerSecurityIntegrationTest.java +++ b/clokey-api/src/test/java/org/clokey/global/config/security/SwaggerSecurityIntegrationTest.java @@ -14,39 +14,12 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles({"test", "local"}) -@TestPropertySource( - properties = { - "spring.datasource.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false;MODE=MYSQL", - "spring.datasource.driver-class-name=org.h2.Driver", - "spring.datasource.username=sa", - "spring.datasource.password=", - "spring.flyway.enabled=false", - "spring.jpa.hibernate.ddl-auto=none", - "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", - "jwt.access-token-secret=test-access-secret-test-access-secret", - "jwt.refresh-token-secret=test-refresh-secret-test-refresh-secret", - "jwt.access-token-expiration-time=3600000", - "jwt.refresh-token-expiration-time=1209600000", - "jwt.issuer=test-issuer", - "spring.security.oauth2.client.registration.kakao.client-id=test", - "spring.security.oauth2.client.registration.kakao.client-secret=test", - "spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost/login/oauth2/code/kakao", - "spring.security.oauth2.client.registration.apple.client-id=test", - "spring.security.oauth2.client.registration.apple.client-secret=test", - "spring.security.oauth2.client.registration.apple.redirect-uri=http://localhost/login/oauth2/code/apple", - "external.api.ai-server-ip=http://localhost", - "external.api.cloth-inference-path=/cloth", - "external.api.style-inference-path=/style", - "external.api.cloth-detect-path=/detect", - "firebase.credentials-path=dummy" - }) +@ActiveProfiles("test") class SwaggerSecurityIntegrationTest { @Autowired private MockMvc mockMvc; diff --git a/clokey-api/src/test/resources/application-test.yml b/clokey-api/src/test/resources/application-test.yml index 1e184c2a..bbe7e7d2 100644 --- a/clokey-api/src/test/resources/application-test.yml +++ b/clokey-api/src/test/resources/application-test.yml @@ -11,12 +11,61 @@ spring: meilisearch: url: ${MEILISEARCH_ENDPOINT:http://localhost:7700} api-key: ${MEILISEARCH_KEY:dummy} + security: + oauth2: + client: + registration: + kakao: + client-id: test-kakao-client-id + client-secret: test-kakao-client-secret + redirect-uri: "${app.base-url.dev}/login/oauth2/code/kakao" + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - openid + provider: kakao + apple: + client-id: test-apple-client-id + client-secret: test-apple-client-secret + redirect-uri: "${app.base-url.dev}/login/oauth2/code/apple" + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + scope: + - openid + - email + provider: apple + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: sub + jwk-set-uri: https://kauth.kakao.com/.well-known/jwks.json + apple: + authorization-uri: https://appleid.apple.com/auth/authorize + token-uri: https://appleid.apple.com/auth/token + jwk-set-uri: https://appleid.apple.com/auth/keys + user-name-attribute: sub + +jwt: + access-token-secret: test-access-token-secret-key-for-clokey-test-profile + refresh-token-secret: test-refresh-token-secret-key-for-clokey-test-profile + access-token-expiration-time: 3600000 + refresh-token-expiration-time: 1209600000 + issuer: clokey-test oci: objectstorage: namespace: test-namespace bucket: test-bucket +external: + api: + ai-server-ip: localhost:9999 + cloth-inference-path: /test/cloth-inference + style-inference-path: /test/style-inference + cloth-detect-path: /test/cloth-detect + firebase: type: dummy project-id: dummy @@ -32,3 +81,10 @@ firebase: swagger: username: swagger password: swagger + +app: + oauth: + redirect-scheme: clokey + base-url: + dev: http://localhost:8080 + prod: http://localhost:8080 From 82345cf7abc3e5d3492b66fbf508c840c10d5103 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 5 Aug 2026 22:28:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=EC=98=B7=20=EA=B2=80=EC=83=89=20Que?= =?UTF-8?q?ryDSL=20=ED=94=84=EB=A1=9C=EC=A0=9D=EC=85=98=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=B0=8F=20=EC=95=88=20=EC=9E=85=EC=96=B4=EB=B3=B8?= =?UTF-8?q?=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EC=B6=94=EC=B2=9C=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/search/repository/SearchRepositoryCustomImpl.java | 1 + .../org/clokey/domain/search/service/SearchServiceImpl.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/search/repository/SearchRepositoryCustomImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/repository/SearchRepositoryCustomImpl.java index 31ae4153..e5090004 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/repository/SearchRepositoryCustomImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/repository/SearchRepositoryCustomImpl.java @@ -40,6 +40,7 @@ public Slice findClothesByKeyword( cloth.clothImageUrl, cloth.brand, cloth.name, + cloth.category.parent.name, cloth.category.name)) .from(cloth) .where( diff --git a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java index b26dc005..b0fadfdc 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java @@ -302,7 +302,7 @@ private List computeAndCacheRecommendations( if (!untriedStyleIds.isEmpty()) { searchRecommendRepository - .findBestHistoryForUntriedStyle(excludedMemberIds, untriedStyleIds) + .findBestHistoryForUntriedStyle(excludedMemberIds, userUsedStyleIds) .ifPresent( row -> results.add( From bbb43e89fa7c1c77e04a46c1c1888b9ca4ffe601 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 5 Aug 2026 22:30:16 +0900 Subject: [PATCH 3/4] =?UTF-8?q?test:=20Search=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8(=EC=98=B7=20=EA=B2=80=EC=83=89/=EC=B6=94=EC=B2=9C)=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B0=8F=20MeiliSearch=20TestCon?= =?UTF-8?q?tainers=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meilisearch-it 프로파일 도입으로 실제 Meilisearch(TestContainers)와 Noop 리포지토리를 전환 가능하게 배선 - 검색/추천 서비스, 문서 변환, 컨트롤러 단위 테스트 및 실제 컨테이너 기반 인덱싱·검색·필터·정렬 통합 테스트 추가 --- clokey-api/build.gradle | 4 + .../repository/MeiliSearchRepositoryImpl.java | 2 +- .../clokey/MeiliSearchIntegrationTest.java | 58 +++ .../controller/SearchControllerTest.java | 272 +++++++++++ .../repository/NoopSearchRepository.java | 2 +- .../service/SearchDocumentServiceTest.java | 228 +++++++++ .../SearchMeiliSearchIntegrationTest.java | 298 ++++++++++++ .../search/service/SearchServiceTest.java | 459 ++++++++++++++++++ .../org/clokey/config/MeiliSearchConfig.java | 2 +- 9 files changed, 1322 insertions(+), 3 deletions(-) create mode 100644 clokey-api/src/test/java/org/clokey/MeiliSearchIntegrationTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/search/controller/SearchControllerTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/search/service/SearchDocumentServiceTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/search/service/SearchMeiliSearchIntegrationTest.java create mode 100644 clokey-api/src/test/java/org/clokey/domain/search/service/SearchServiceTest.java diff --git a/clokey-api/build.gradle b/clokey-api/build.gradle index 01739638..3e4726d4 100644 --- a/clokey-api/build.gradle +++ b/clokey-api/build.gradle @@ -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' } diff --git a/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java index 7920448e..f5f7a68c 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java @@ -21,7 +21,7 @@ @Repository @RequiredArgsConstructor -@Profile("!test") +@Profile({"!test", "meilisearch-it"}) public class MeiliSearchRepositoryImpl implements SearchRepository { private static final String HISTORY_INDEX = "histories"; diff --git a/clokey-api/src/test/java/org/clokey/MeiliSearchIntegrationTest.java b/clokey-api/src/test/java/org/clokey/MeiliSearchIntegrationTest.java new file mode 100644 index 00000000..26135be0 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/MeiliSearchIntegrationTest.java @@ -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(); + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/search/controller/SearchControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/search/controller/SearchControllerTest.java new file mode 100644 index 00000000..165fe528 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/search/controller/SearchControllerTest.java @@ -0,0 +1,272 @@ +package org.clokey.domain.search.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willDoNothing; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import org.clokey.domain.cloth.dto.response.ClothListResponse; +import org.clokey.domain.search.dto.response.SearchedHistoryResponse; +import org.clokey.domain.search.dto.response.SearchedMemberResponse; +import org.clokey.domain.search.dto.response.SearchingRecommendResponse; +import org.clokey.domain.search.enums.HistorySearchSortType; +import org.clokey.domain.search.service.SearchService; +import org.clokey.global.paging.SortDirection; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(SearchController.class) +@AutoConfigureMockMvc(addFilters = false) +class SearchControllerTest { + + @Autowired private MockMvc mockMvc; + + @MockitoBean private SearchService searchService; + + @Nested + class 옷_검색_요청_시 { + + @Test + void 유효한_요청이면_옷_목록을_반환한다() throws Exception { + List content = + List.of( + new ClothListResponse( + 1L, + "testImageUrl", + "testBrand", + "testName", + "testParent", + "testCategory")); + given(searchService.searchClothes("키워드", null, 10, SortDirection.DESC, null, null)) + .willReturn(new SliceResponse<>(content, true)); + + ResultActions perform = + mockMvc.perform( + get("/search/clothes").param("keyword", "키워드").param("size", "10")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.result.content[0].clothId").value(1)) + .andExpect(jsonPath("$.result.isLast").value(true)); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "0"}) + void 페이지_크기가_0_이하이면_예외가_발생한다(String size) throws Exception { + ResultActions perform = + mockMvc.perform( + get("/search/clothes").param("keyword", "키워드").param("size", size)); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + + @Test + void seasons_파라미터에_존재하지_않는_계절값이면_예외가_발생한다() throws Exception { + ResultActions perform = + mockMvc.perform( + get("/search/clothes") + .param("keyword", "키워드") + .param("size", "10") + .param("seasons", "SPRINGG")); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + } + + @Nested + class 기록_검색_요청_시 { + + @Test + void 유효한_요청이면_기록_목록을_반환한다() throws Exception { + List content = + List.of( + new SearchedHistoryResponse( + 1L, "testImageUrl", "testProfile", "testNick")); + given( + searchService.searchHistoryByHashtagsAndCategories( + "키워드", 0L, 10, HistorySearchSortType.LATEST)) + .willReturn(new SliceResponse<>(content, true)); + + ResultActions perform = + mockMvc.perform( + get("/search/histories") + .param("keyword", "키워드") + .param("page", "0") + .param("size", "10")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.result.content[0].historyId").value(1)); + } + + @Test + void 정렬조건을_지정하면_해당_정렬로_조회한다() throws Exception { + given( + searchService.searchHistoryByHashtagsAndCategories( + "키워드", 0L, 10, HistorySearchSortType.POPULAR)) + .willReturn(new SliceResponse<>(List.of(), true)); + + ResultActions perform = + mockMvc.perform( + get("/search/histories") + .param("keyword", "키워드") + .param("page", "0") + .param("size", "10") + .param("sort", "POPULAR")); + + perform.andExpect(status().isOk()).andExpect(jsonPath("$.isSuccess").value(true)); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "0"}) + void 페이지_크기가_0_이하이면_예외가_발생한다(String size) throws Exception { + ResultActions perform = + mockMvc.perform( + get("/search/histories") + .param("keyword", "키워드") + .param("page", "0") + .param("size", size)); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + } + + @Nested + class 기록_검색엔진_동기화_요청_시 { + + @Test + void 전체_동기화_요청이면_성공을_반환한다() throws Exception { + willDoNothing().given(searchService).syncAllHistories(); + + ResultActions perform = mockMvc.perform(get("/search/histories/sync-all")); + + perform.andExpect(status().isOk()).andExpect(jsonPath("$.isSuccess").value(true)); + } + + @Test + void 전체_동기화_해제_요청이면_성공을_반환한다() throws Exception { + willDoNothing().given(searchService).unSyncAllHistories(); + + ResultActions perform = mockMvc.perform(get("/search/histories/unsync-all")); + + perform.andExpect(status().isOk()).andExpect(jsonPath("$.isSuccess").value(true)); + } + } + + @Nested + class 유저_검색_요청_시 { + + @Test + void 유효한_요청이면_유저_목록을_반환한다() throws Exception { + List content = + List.of(new SearchedMemberResponse(1L, "testProfile", "testNick")); + given(searchService.searchUserByNickname("닉네임", 0L, 10)) + .willReturn(new SliceResponse<>(content, true)); + + ResultActions perform = + mockMvc.perform( + get("/search/members") + .param("keyword", "닉네임") + .param("page", "0") + .param("size", "10")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.result.content[0].memberId").value(1)); + } + + @ParameterizedTest + @ValueSource(strings = {"-1", "0"}) + void 페이지_크기가_0_이하이면_예외가_발생한다(String size) throws Exception { + ResultActions perform = + mockMvc.perform( + get("/search/members") + .param("keyword", "닉네임") + .param("page", "0") + .param("size", size)); + + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")); + } + } + + @Nested + class 유저_검색엔진_동기화_요청_시 { + + @Test + void 전체_동기화_요청이면_성공을_반환한다() throws Exception { + willDoNothing().given(searchService).syncAllMembers(); + + ResultActions perform = mockMvc.perform(get("/search/members/sync-all")); + + perform.andExpect(status().isOk()).andExpect(jsonPath("$.isSuccess").value(true)); + } + + @Test + void 전체_동기화_해제_요청이면_성공을_반환한다() throws Exception { + willDoNothing().given(searchService).unSyncAllMembers(); + + ResultActions perform = mockMvc.perform(get("/search/members/unsync-all")); + + perform.andExpect(status().isOk()).andExpect(jsonPath("$.isSuccess").value(true)); + } + } + + @Nested + class 검색_탭_기록_추천_요청_시 { + + @Test + void 유효한_요청이면_추천_기록_목록을_반환한다() throws Exception { + List response = + List.of( + new SearchingRecommendResponse( + 1L, + 2L, + "UNTRIED_STYLE", + "분위기 전환이 필요할 때", + "포멀", + "testImageUrl")); + given(searchService.recommendInSearching()).willReturn(response); + + ResultActions perform = mockMvc.perform(get("/search/recommendations")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.result[0].historyId").value(1)) + .andExpect(jsonPath("$.result[0].recommendType").value("UNTRIED_STYLE")); + } + + @Test + void 추천할_기록이_없으면_빈_목록을_반환한다() throws Exception { + given(searchService.recommendInSearching()).willReturn(List.of()); + + ResultActions perform = mockMvc.perform(get("/search/recommendations")); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result").isEmpty()); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/search/repository/NoopSearchRepository.java b/clokey-api/src/test/java/org/clokey/domain/search/repository/NoopSearchRepository.java index 1689a069..bdfa8feb 100644 --- a/clokey-api/src/test/java/org/clokey/domain/search/repository/NoopSearchRepository.java +++ b/clokey-api/src/test/java/org/clokey/domain/search/repository/NoopSearchRepository.java @@ -12,7 +12,7 @@ import org.springframework.stereotype.Repository; @Repository -@Profile("test") +@Profile("test & !meilisearch-it") @Primary public class NoopSearchRepository implements SearchRepository { diff --git a/clokey-api/src/test/java/org/clokey/domain/search/service/SearchDocumentServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchDocumentServiceTest.java new file mode 100644 index 00000000..3002ef7b --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchDocumentServiceTest.java @@ -0,0 +1,228 @@ +package org.clokey.domain.search.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.category.entity.Category; +import org.clokey.cloth.entity.Cloth; +import org.clokey.cloth.enums.Season; +import org.clokey.domain.category.repository.CategoryRepository; +import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.history.repository.HashtagRepository; +import org.clokey.domain.history.repository.HistoryClothTagRepository; +import org.clokey.domain.history.repository.HistoryHashtagRepository; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.HistoryStyleRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.history.repository.StyleRepository; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.search.document.HistoryDocument; +import org.clokey.domain.search.document.MemberDocument; +import org.clokey.history.entity.Hashtag; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryClothTag; +import org.clokey.history.entity.HistoryHashtag; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.HistoryStyle; +import org.clokey.history.entity.Situation; +import org.clokey.history.entity.Style; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class SearchDocumentServiceTest extends IntegrationTest { + + @Autowired private SearchDocumentService searchDocumentService; + @Autowired private MemberRepository memberRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private ClothRepository clothRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private StyleRepository styleRepository; + @Autowired private HistoryStyleRepository historyStyleRepository; + @Autowired private HashtagRepository hashtagRepository; + @Autowired private HistoryHashtagRepository historyHashtagRepository; + @Autowired private HistoryImageRepository historyImageRepository; + @Autowired private HistoryClothTagRepository historyClothTagRepository; + @Autowired private MemberLikeRepository memberLikeRepository; + + private Member member; + private Situation situation; + + @BeforeEach + void setUp() { + member = + memberRepository.save( + Member.createMember( + "test@test.com", + "testNick", + OauthInfo.createOauthInfo("test-oauth", OauthProvider.KAKAO))); + situation = situationRepository.save(Situation.createSituation("situation")); + } + + @Nested + class 기록을_문서로_변환할_때 { + + @Test + void 기록의_모든_정보를_HistoryDocument로_변환한다() { + Category category = categoryRepository.save(Category.createCategory("상의", null)); + Cloth cloth = + clothRepository.save( + Cloth.createCloth( + "clothImage", + null, + null, + null, + List.of(Season.SPRING), + category, + member)); + Style style = styleRepository.save(Style.createStyle("캐주얼")); + Hashtag hashtag = hashtagRepository.save(Hashtag.createHashtag("가을룩")); + + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", member, situation)); + historyStyleRepository.save(HistoryStyle.createHistoryStyle(history, style)); + historyHashtagRepository.save(HistoryHashtag.createHistoryHashtag(history, hashtag)); + HistoryImage image = + historyImageRepository.save( + HistoryImage.createHistoryImage("historyImageUrl", history)); + historyClothTagRepository.save( + HistoryClothTag.createHistoryClothTag(image, cloth, 1.0, 1.0)); + + Member liker = + memberRepository.save( + Member.createMember( + "liker@test.com", + "likerNick", + OauthInfo.createOauthInfo("liker-oauth", OauthProvider.KAKAO))); + memberLikeRepository.save(MemberLike.createMemberLike(liker, history)); + + HistoryDocument document = searchDocumentService.toHistoryDocument(history.getId()); + + assertThat(document.getId()).isEqualTo(history.getId().toString()); + assertThat(document.getMemberId()).isEqualTo(member.getId()); + assertThat(document.getBanned()).isFalse(); + assertThat(document.getLikeCount()).isEqualTo(1L); + assertThat(document.getCreatedAt()) + .isEqualTo( + history.getCreatedAt() + .atZone(ZoneOffset.UTC) + .toInstant() + .toEpochMilli()); + assertThat(document.getHistoryImageUrl()).isEqualTo("historyImageUrl"); + assertThat(document.getProfileImageUrl()).isEqualTo(member.getProfileImageUrl()); + assertThat(document.getNickname()).isEqualTo("testNick"); + assertThat(document.getStyleNames()).containsExactly("캐주얼"); + assertThat(document.getHashtagNames()).containsExactly("가을룩"); + assertThat(document.getCategoryNames()).containsExactly("상의"); + } + + @Test + void 신고된_기록은_banned가_true로_변환된다() { + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", member, situation)); + history.ban(); + historyRepository.save(history); + + HistoryDocument document = searchDocumentService.toHistoryDocument(history.getId()); + + assertThat(document.getBanned()).isTrue(); + } + + @Test + void 이미지가_없으면_historyImageUrl은_null이다() { + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", member, situation)); + + HistoryDocument document = searchDocumentService.toHistoryDocument(history.getId()); + + assertThat(document.getHistoryImageUrl()).isNull(); + } + + @Test + void 같은_카테고리가_여러번_태깅되어도_중복없이_반환한다() { + Category category = categoryRepository.save(Category.createCategory("상의", null)); + Cloth cloth1 = + clothRepository.save( + Cloth.createCloth( + "clothImage1", + null, + null, + null, + List.of(Season.SPRING), + category, + member)); + Cloth cloth2 = + clothRepository.save( + Cloth.createCloth( + "clothImage2", + null, + null, + null, + List.of(Season.SPRING), + category, + member)); + + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", member, situation)); + HistoryImage image = + historyImageRepository.save( + HistoryImage.createHistoryImage("historyImageUrl", history)); + historyClothTagRepository.save( + HistoryClothTag.createHistoryClothTag(image, cloth1, 1.0, 1.0)); + historyClothTagRepository.save( + HistoryClothTag.createHistoryClothTag(image, cloth2, 2.0, 2.0)); + + HistoryDocument document = searchDocumentService.toHistoryDocument(history.getId()); + + assertThat(document.getCategoryNames()).containsExactly("상의"); + } + + @Test + void 존재하지_않는_기록이면_예외가_발생한다() { + assertThatThrownBy(() -> searchDocumentService.toHistoryDocument(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("History not found: 999"); + } + } + + @Nested + class 유저를_문서로_변환할_때 { + + @Test + void 유저의_모든_정보를_MemberDocument로_변환한다() { + MemberDocument document = searchDocumentService.toMemberDocument(member.getId()); + + assertThat(document.getId()).isEqualTo(member.getId().toString()); + assertThat(document.getMemberStatus()).isEqualTo(member.getMemberStatus().name()); + assertThat(document.getProfileImageUrl()).isEqualTo(member.getProfileImageUrl()); + assertThat(document.getNickname()).isEqualTo("testNick"); + } + + @Test + void 존재하지_않는_유저면_예외가_발생한다() { + assertThatThrownBy(() -> searchDocumentService.toMemberDocument(999L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Member not found: 999"); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/search/service/SearchMeiliSearchIntegrationTest.java b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchMeiliSearchIntegrationTest.java new file mode 100644 index 00000000..9e39ce0b --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchMeiliSearchIntegrationTest.java @@ -0,0 +1,298 @@ +package org.clokey.domain.search.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.BDDMockito.given; + +import io.vanslog.spring.data.meilisearch.core.MeilisearchOperations; +import java.time.Duration; +import java.time.LocalDate; +import java.util.List; +import java.util.function.Consumer; +import org.clokey.MeiliSearchIntegrationTest; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.HistoryStyleRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.history.repository.StyleRepository; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.search.document.HistoryDocument; +import org.clokey.domain.search.document.MemberDocument; +import org.clokey.domain.search.dto.response.SearchedHistoryResponse; +import org.clokey.domain.search.dto.response.SearchedMemberResponse; +import org.clokey.domain.search.enums.HistorySearchSortType; +import org.clokey.domain.search.repository.MeilisearchHistoryRepository; +import org.clokey.domain.search.repository.MeilisearchMemberRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryStyle; +import org.clokey.history.entity.Situation; +import org.clokey.history.entity.Style; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Block; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.MemberStatus; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +/** + * 실제 Meilisearch 컨테이너를 대상으로 기록/유저 검색 엔진 동기화 및 검색 기능을 검증하는 통합 테스트입니다. "test" 프로파일에서는 {@link + * org.clokey.domain.search.repository.NoopSearchRepository} 로 대체되어 검증되지 않는 경로이므로, 여기서 실제 + * Meilisearch 연동 동작을 별도로 검증합니다. + */ +class SearchMeiliSearchIntegrationTest extends MeiliSearchIntegrationTest { + + @Autowired private SearchService searchService; + @Autowired private MemberRepository memberRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private StyleRepository styleRepository; + @Autowired private HistoryStyleRepository historyStyleRepository; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private BlockRepository blockRepository; + @Autowired private MeilisearchOperations meilisearchOperations; + @Autowired private MeilisearchHistoryRepository meilisearchHistoryRepository; + @Autowired private MeilisearchMemberRepository meilisearchMemberRepository; + + @MockitoBean private MemberUtil memberUtil; + + @BeforeEach + void setUpMeilisearchIndices() { + meilisearchOperations.applySettings(HistoryDocument.class); + meilisearchOperations.applySettings(MemberDocument.class); + meilisearchHistoryRepository.deleteAll(); + meilisearchMemberRepository.deleteAll(); + } + + private Member createMember(String email, String nickname) { + return Member.createMember( + email, nickname, OauthInfo.createOauthInfo(email + "-oauth", OauthProvider.KAKAO)); + } + + private void awaitHistorySearch( + String keyword, HistorySearchSortType sort, Consumer> assertion) { + await().atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofMillis(300)) + .untilAsserted( + () -> { + SliceResponse response = + searchService.searchHistoryByHashtagsAndCategories( + keyword, 0L, 10, sort); + assertion.accept( + response.content().stream() + .map(SearchedHistoryResponse::historyId) + .toList()); + }); + } + + private void awaitMemberSearch(String keyword, Consumer> assertion) { + await().atMost(Duration.ofSeconds(10)) + .pollInterval(Duration.ofMillis(300)) + .untilAsserted( + () -> { + SliceResponse response = + searchService.searchUserByNickname(keyword, 0L, 10); + assertion.accept( + response.content().stream() + .map(SearchedMemberResponse::memberId) + .toList()); + }); + } + + @Nested + class 기록을_검색할_때 { + + private Member owner; + private Situation situation; + private Style style; + + @BeforeEach + void setUp() { + owner = memberRepository.save(createMember("owner@test.com", "ownerNick")); + situation = situationRepository.save(Situation.createSituation("testSituation")); + style = styleRepository.save(Style.createStyle("빈티지")); + given(memberUtil.getCurrentMember()).willReturn(owner); + } + + private History createTaggedHistory(Member member, LocalDate date, boolean banned) { + History history = + historyRepository.save( + History.createHistory(date, "testContent", member, situation)); + historyStyleRepository.save(HistoryStyle.createHistoryStyle(history, style)); + if (banned) { + history.ban(); + historyRepository.save(history); + } + return history; + } + + @Test + void 스타일_키워드로_검색하면_해당_기록을_반환한다() { + History history = createTaggedHistory(owner, LocalDate.of(2026, 1, 1), false); + searchService.syncAllHistories(); + + awaitHistorySearch( + "빈티지", + HistorySearchSortType.LATEST, + historyIds -> assertThat(historyIds).contains(history.getId())); + } + + @Test + void 신고된_기록은_검색결과에서_제외된다() { + History bannedHistory = createTaggedHistory(owner, LocalDate.of(2026, 1, 1), true); + searchService.syncAllHistories(); + + awaitHistorySearch( + "빈티지", + HistorySearchSortType.LATEST, + historyIds -> assertThat(historyIds).doesNotContain(bannedHistory.getId())); + } + + @Test + void 차단한_유저의_기록은_검색결과에서_제외된다() { + Member blocked = memberRepository.save(createMember("blocked@test.com", "blockedNick")); + blockRepository.save(Block.createBlock(owner, blocked)); + + History blockedHistory = createTaggedHistory(blocked, LocalDate.of(2026, 1, 1), false); + searchService.syncAllHistories(); + + awaitHistorySearch( + "빈티지", + HistorySearchSortType.LATEST, + historyIds -> assertThat(historyIds).doesNotContain(blockedHistory.getId())); + } + + @Test + void 좋아요가_많은_순으로_인기순_정렬한다() { + History lessLiked = createTaggedHistory(owner, LocalDate.of(2026, 1, 1), false); + History moreLiked = createTaggedHistory(owner, LocalDate.of(2026, 1, 2), false); + + Member liker1 = memberRepository.save(createMember("liker1@test.com", "liker1")); + Member liker2 = memberRepository.save(createMember("liker2@test.com", "liker2")); + memberLikeRepository.save(MemberLike.createMemberLike(liker1, moreLiked)); + memberLikeRepository.save(MemberLike.createMemberLike(liker2, moreLiked)); + + searchService.syncAllHistories(); + + awaitHistorySearch( + "빈티지", + HistorySearchSortType.POPULAR, + historyIds -> { + assertThat(historyIds).hasSize(2); + assertThat(historyIds.get(0)).isEqualTo(moreLiked.getId()); + assertThat(historyIds.get(1)).isEqualTo(lessLiked.getId()); + }); + } + } + + @Nested + class 유저를_검색할_때 { + + @Test + void 닉네임_키워드로_검색하면_해당_유저를_반환한다() { + Member searcher = + memberRepository.save(createMember("searcher@test.com", "searcherNick")); + Member target = memberRepository.save(createMember("target@test.com", "빈티지러버")); + given(memberUtil.getCurrentMember()).willReturn(searcher); + + searchService.syncAllMembers(); + + awaitMemberSearch("빈티지", memberIds -> assertThat(memberIds).contains(target.getId())); + } + + @Test + void 본인은_검색결과에서_제외된다() { + Member searcher = memberRepository.save(createMember("searcher2@test.com", "빈티지매니아")); + given(memberUtil.getCurrentMember()).willReturn(searcher); + + searchService.syncAllMembers(); + + awaitMemberSearch( + "빈티지", memberIds -> assertThat(memberIds).doesNotContain(searcher.getId())); + } + + @Test + void 차단한_유저는_검색결과에서_제외된다() { + Member searcher = + memberRepository.save(createMember("searcher3@test.com", "searcherNick3")); + Member blocked = memberRepository.save(createMember("blocked2@test.com", "빈티지블락")); + blockRepository.save(Block.createBlock(searcher, blocked)); + given(memberUtil.getCurrentMember()).willReturn(searcher); + + searchService.syncAllMembers(); + + awaitMemberSearch( + "빈티지", memberIds -> assertThat(memberIds).doesNotContain(blocked.getId())); + } + + @Test + void 정지된_유저는_검색결과에서_제외된다() { + Member searcher = + memberRepository.save(createMember("searcher4@test.com", "searcherNick4")); + Member banned = memberRepository.save(createMember("banned@test.com", "빈티지밴")); + banned.updateMemberStatus(MemberStatus.BANNED); + memberRepository.save(banned); + given(memberUtil.getCurrentMember()).willReturn(searcher); + + searchService.syncAllMembers(); + + awaitMemberSearch( + "빈티지", memberIds -> assertThat(memberIds).doesNotContain(banned.getId())); + } + } + + @Nested + class 검색엔진_동기화_해제를_할_때 { + + @Autowired private SituationRepository situationRepository; + @Autowired private StyleRepository styleRepository; + @Autowired private HistoryStyleRepository historyStyleRepository; + + @Test + void unSyncAllHistories_호출하면_모든_기록이_검색결과에서_제거된다() { + Member owner = memberRepository.save(createMember("owner2@test.com", "owner2Nick")); + Situation situation = + situationRepository.save(Situation.createSituation("testSituation2")); + Style style = styleRepository.save(Style.createStyle("빈티지2")); + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 2), "testContent", owner, situation)); + historyStyleRepository.save(HistoryStyle.createHistoryStyle(history, style)); + given(memberUtil.getCurrentMember()).willReturn(owner); + + searchService.syncAllHistories(); + awaitHistorySearch( + "빈티지2", + HistorySearchSortType.LATEST, + historyIds -> assertThat(historyIds).contains(history.getId())); + + searchService.unSyncAllHistories(); + awaitHistorySearch( + "빈티지2", + HistorySearchSortType.LATEST, + historyIds -> assertThat(historyIds).isEmpty()); + } + + @Test + void unSyncAllMembers_호출하면_모든_유저가_검색결과에서_제거된다() { + Member searcher = + memberRepository.save(createMember("searcher5@test.com", "searcherNick5")); + Member target = memberRepository.save(createMember("target2@test.com", "빈티지헌터")); + given(memberUtil.getCurrentMember()).willReturn(searcher); + + searchService.syncAllMembers(); + awaitMemberSearch("빈티지헌터", memberIds -> assertThat(memberIds).contains(target.getId())); + + searchService.unSyncAllMembers(); + awaitMemberSearch("빈티지헌터", memberIds -> assertThat(memberIds).isEmpty()); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/search/service/SearchServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchServiceTest.java new file mode 100644 index 00000000..60f59d52 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/search/service/SearchServiceTest.java @@ -0,0 +1,459 @@ +package org.clokey.domain.search.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.RedisCleaner; +import org.clokey.category.entity.Category; +import org.clokey.cloth.entity.Cloth; +import org.clokey.cloth.enums.Season; +import org.clokey.domain.category.exception.CategoryErrorCode; +import org.clokey.domain.category.repository.CategoryRepository; +import org.clokey.domain.cloth.dto.response.ClothListResponse; +import org.clokey.domain.cloth.repository.ClothRepository; +import org.clokey.domain.history.repository.HashtagRepository; +import org.clokey.domain.history.repository.HistoryClothTagRepository; +import org.clokey.domain.history.repository.HistoryHashtagRepository; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.HistoryStyleRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.history.repository.StyleRepository; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.domain.search.dto.response.SearchedHistoryResponse; +import org.clokey.domain.search.dto.response.SearchedMemberResponse; +import org.clokey.domain.search.dto.response.SearchingRecommendResponse; +import org.clokey.domain.search.enums.HistorySearchSortType; +import org.clokey.domain.search.repository.SearchRepository; +import org.clokey.exception.BaseCustomException; +import org.clokey.global.paging.SortDirection; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.Hashtag; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryClothTag; +import org.clokey.history.entity.HistoryHashtag; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.HistoryStyle; +import org.clokey.history.entity.Situation; +import org.clokey.history.entity.Style; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Block; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +class SearchServiceTest extends IntegrationTest { + + @Autowired private SearchService searchService; + @Autowired private MemberRepository memberRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private ClothRepository clothRepository; + @Autowired private BlockRepository blockRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private StyleRepository styleRepository; + @Autowired private HistoryStyleRepository historyStyleRepository; + @Autowired private HistoryHashtagRepository historyHashtagRepository; + @Autowired private HistoryImageRepository historyImageRepository; + @Autowired private HistoryClothTagRepository historyClothTagRepository; + @Autowired private HashtagRepository hashtagRepository; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private RedisCleaner redisCleaner; + @Autowired private RedisTemplate redisTemplate; + + @MockitoBean private MemberUtil memberUtil; + @MockitoBean private SearchRepository searchRepository; + + private Member currentMember; + + @BeforeEach + void setUp() { + redisCleaner.flushAll(); + currentMember = + memberRepository.save( + Member.createMember( + "current@test.com", + "currentNick", + OauthInfo.createOauthInfo("current-oauth", OauthProvider.KAKAO))); + given(memberUtil.getCurrentMember()).willReturn(currentMember); + } + + @Nested + class 옷을_검색할_때 { + + private Category parentCategory; + private Category category; + + @BeforeEach + void setUp() { + parentCategory = categoryRepository.save(Category.createCategory("상의", null)); + category = categoryRepository.save(Category.createCategory("후드티", parentCategory)); + } + + private Cloth createCloth(String name, String brand, Season season) { + return clothRepository.save( + Cloth.createCloth( + "testImageUrl", + null, + name, + brand, + List.of(season), + category, + currentMember)); + } + + @Test + void 이름_키워드로_검색하면_해당_옷을_반환한다() { + Cloth cloth = createCloth("파란색 후드티", "나이키", Season.SPRING); + createCloth("빨간색 셔츠", "아디다스", Season.SPRING); + + SliceResponse response = + searchService.searchClothes("파란색", null, 10, SortDirection.DESC, null, null); + + assertThat(response.content()).extracting("clothId").containsExactly(cloth.getId()); + } + + @Test + void 브랜드_키워드로_검색하면_해당_옷을_반환한다() { + Cloth cloth = createCloth("후드티", "나이키", Season.SPRING); + createCloth("셔츠", "아디다스", Season.SPRING); + + SliceResponse response = + searchService.searchClothes("나이키", null, 10, SortDirection.DESC, null, null); + + assertThat(response.content()).extracting("clothId").containsExactly(cloth.getId()); + } + + @Test + void 상위_카테고리로_검색하면_하위_카테고리_옷들을_모두_반환한다() { + Cloth cloth = createCloth("후드티", "나이키", Season.SPRING); + + SliceResponse response = + searchService.searchClothes( + "후드티", null, 10, SortDirection.DESC, parentCategory.getId(), null); + + assertThat(response.content()).extracting("clothId").containsExactly(cloth.getId()); + } + + @Test + void 계절_조건으로_검색하면_해당_계절의_옷만_반환한다() { + createCloth("여름옷", "나이키", Season.SUMMER); + Cloth springCloth = createCloth("여름옷", "나이키", Season.SPRING); + + SliceResponse response = + searchService.searchClothes( + "여름옷", null, 10, SortDirection.DESC, null, List.of(Season.SPRING)); + + assertThat(response.content()) + .extracting("clothId") + .containsExactly(springCloth.getId()); + } + + @Test + void 존재하지_않는_카테고리로_검색하면_예외가_발생한다() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> + searchService.searchClothes( + "키워드", null, 10, SortDirection.DESC, 999L, null)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(CategoryErrorCode.CATEGORY_NOT_FOUND.getMessage()); + } + + @Test + void 일치하는_옷이_없으면_빈_목록을_반환한다() { + createCloth("후드티", "나이키", Season.SPRING); + + SliceResponse response = + searchService.searchClothes( + "존재하지않는키워드", null, 10, SortDirection.DESC, null, null); + + assertThat(response.content()).isEmpty(); + } + } + + @Nested + class 기록을_검색할_때 { + + @Test + void 차단한_유저를_제외하고_검색엔진에_위임한다() { + Member blocked = + memberRepository.save( + Member.createMember( + "blocked@test.com", + "blockedNick", + OauthInfo.createOauthInfo( + "blocked-oauth", OauthProvider.KAKAO))); + blockRepository.save(Block.createBlock(currentMember, blocked)); + + SliceResponse mockResponse = + new SliceResponse<>(List.of(), true); + given( + searchRepository.findHistoriesByKeyword( + eq("키워드"), + eq(0L), + eq(10), + eq(HistorySearchSortType.LATEST), + anyList())) + .willReturn(mockResponse); + + SliceResponse response = + searchService.searchHistoryByHashtagsAndCategories( + "키워드", 0L, 10, HistorySearchSortType.LATEST); + + assertThat(response).isEqualTo(mockResponse); + verify(searchRepository) + .findHistoriesByKeyword( + eq("키워드"), + eq(0L), + eq(10), + eq(HistorySearchSortType.LATEST), + eq(List.of(blocked.getId()))); + } + } + + @Nested + class 유저를_검색할_때 { + + @Test + void 차단한_유저와_본인을_제외하고_검색엔진에_위임한다() { + Member blocked = + memberRepository.save( + Member.createMember( + "blocked2@test.com", + "blockedNick2", + OauthInfo.createOauthInfo( + "blocked2-oauth", OauthProvider.KAKAO))); + blockRepository.save(Block.createBlock(currentMember, blocked)); + + SliceResponse mockResponse = + new SliceResponse<>(List.of(), true); + given(searchRepository.findUsersByKeyword(eq("닉네임"), eq(0L), eq(10), anyList())) + .willReturn(mockResponse); + + SliceResponse response = + searchService.searchUserByNickname("닉네임", 0L, 10); + + assertThat(response).isEqualTo(mockResponse); + verify(searchRepository) + .findUsersByKeyword( + eq("닉네임"), + eq(0L), + eq(10), + eq(List.of(blocked.getId(), currentMember.getId()))); + } + } + + @Nested + class 검색엔진에_기록을_동기화할_때 { + + @Test + void 기록이_존재하면_검색엔진에_저장한다() { + Situation situation = situationRepository.save(Situation.createSituation("situation")); + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", currentMember, situation)); + + searchService.syncAllHistories(); + + verify(searchRepository, times(1)).saveAllHistories(anyList()); + } + + @Test + void 기록이_없으면_검색엔진을_호출하지_않는다() { + searchService.syncAllHistories(); + + verify(searchRepository, never()).saveAllHistories(anyList()); + } + } + + @Nested + class 검색엔진에서_기록_동기화를_해제할_때 { + + @Test + void 기록이_존재하면_모두_삭제한다() { + Situation situation = situationRepository.save(Situation.createSituation("situation")); + History history = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", currentMember, situation)); + + searchService.unSyncAllHistories(); + + verify(searchRepository, times(1)).deleteHistory(eq(history.getId().toString())); + } + + @Test + void 기록이_없으면_검색엔진을_호출하지_않는다() { + searchService.unSyncAllHistories(); + + verify(searchRepository, never()).deleteHistory(any()); + } + } + + @Nested + class 검색엔진에_유저를_동기화할_때 { + + @Test + void 유저가_존재하면_검색엔진에_저장한다() { + searchService.syncAllMembers(); + + verify(searchRepository, times(1)).saveAllMembers(anyList()); + } + } + + @Nested + class 검색엔진에서_유저_동기화를_해제할_때 { + + @Test + void 유저가_존재하면_모두_삭제한다() { + searchService.unSyncAllMembers(); + + verify(searchRepository, times(1)).deleteMember(eq(currentMember.getId().toString())); + } + } + + @Nested + class 검색_탭에서_기록을_추천할_때 { + + private Member candidateMember; + private Category category; + private Style usedStyle; + private Style untriedStyle; + private Hashtag hashtag; + private History recommendableHistory; + + @BeforeEach + void setUp() { + candidateMember = + memberRepository.save( + Member.createMember( + "candidate@test.com", + "candidateNick", + OauthInfo.createOauthInfo( + "candidate-oauth", OauthProvider.KAKAO))); + + category = categoryRepository.save(Category.createCategory("상의", null)); + usedStyle = styleRepository.save(Style.createStyle("캐주얼")); + untriedStyle = styleRepository.save(Style.createStyle("포멀")); + hashtag = hashtagRepository.save(Hashtag.createHashtag("가을룩")); + + Situation situation = situationRepository.save(Situation.createSituation("situation")); + + // 현재 유저 본인의 기록: 사용한 스타일 / 자주 입은 카테고리 / 최근 해시태그 산출용 + Cloth myCloth = + clothRepository.save( + Cloth.createCloth( + "myClothImage", + null, + null, + null, + List.of(Season.SPRING), + category, + currentMember)); + History myHistory = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 1), "content", currentMember, situation)); + historyStyleRepository.save(HistoryStyle.createHistoryStyle(myHistory, usedStyle)); + historyHashtagRepository.save(HistoryHashtag.createHistoryHashtag(myHistory, hashtag)); + HistoryImage myHistoryImage = + historyImageRepository.save( + HistoryImage.createHistoryImage("myHistoryImage", myHistory)); + historyClothTagRepository.save( + HistoryClothTag.createHistoryClothTag(myHistoryImage, myCloth, 1.0, 1.0)); + + // 추천 후보가 될 다른 유저의 기록: untried style + 동일 카테고리 + 동일 해시태그 모두 매칭 + Cloth candidateCloth = + clothRepository.save( + Cloth.createCloth( + "candidateClothImage", + null, + null, + null, + List.of(Season.SPRING), + category, + candidateMember)); + recommendableHistory = + historyRepository.save( + History.createHistory( + LocalDate.of(2026, 1, 2), + "candidateContent", + candidateMember, + situation)); + historyStyleRepository.save( + HistoryStyle.createHistoryStyle(recommendableHistory, untriedStyle)); + historyHashtagRepository.save( + HistoryHashtag.createHistoryHashtag(recommendableHistory, hashtag)); + HistoryImage candidateImage = + historyImageRepository.save( + HistoryImage.createHistoryImage( + "candidateHistoryImage", recommendableHistory)); + historyClothTagRepository.save( + HistoryClothTag.createHistoryClothTag( + candidateImage, candidateCloth, 1.0, 1.0)); + + // 카테고리/해시태그 조건은 본인 기록도 함께 만족하므로, 후보 기록에 좋아요를 주어 + // "좋아요 많은 순" 정렬에서 결정적으로 후보 기록이 선택되도록 한다. + memberLikeRepository.save( + MemberLike.createMemberLike(candidateMember, recommendableHistory)); + } + + @Test + void 안읽어본_스타일_자주입은_카테고리_최근_해시태그_기준으로_추천한다() { + List response = searchService.recommendInSearching(); + + assertThat(response).hasSize(3); + assertThat(response).extracting("historyId").containsOnly(recommendableHistory.getId()); + assertThat(response) + .extracting("recommendType") + .containsExactlyInAnyOrder( + "UNTRIED_STYLE", "FREQUENTLY_WORN_CATEGORY", "RECENTLY_USED_HASHTAG"); + } + + @Test + void 추천_결과는_레디스에_캐싱된다() { + searchService.recommendInSearching(); + + Object cached = + redisTemplate.opsForValue().get("search:recommend:" + currentMember.getId()); + assertThat(cached).isNotNull(); + } + + @Test + void 캐시된_추천_결과가_있으면_재계산하지_않고_반환한다() { + List first = searchService.recommendInSearching(); + List second = searchService.recommendInSearching(); + + assertThat(second).isEqualTo(first); + } + + @Test + void 캐시된_추천에_차단한_유저가_포함되면_재계산한다() { + searchService.recommendInSearching(); + blockRepository.save(Block.createBlock(currentMember, candidateMember)); + + List response = searchService.recommendInSearching(); + + assertThat(response).extracting("memberId").doesNotContain(candidateMember.getId()); + } + } +} diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/MeiliSearchConfig.java b/clokey-infrastructure/src/main/java/org/clokey/config/MeiliSearchConfig.java index 361a83f9..92fc628a 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/MeiliSearchConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/MeiliSearchConfig.java @@ -12,7 +12,7 @@ @Configuration @RequiredArgsConstructor @EnableMeilisearchRepositories(basePackages = {"org.clokey.domain.search.repository"}) -@Profile("!test") +@Profile({"!test", "meilisearch-it"}) public class MeiliSearchConfig extends MeilisearchConfiguration { private final MeilisearchProperties meilisearchProperties; From be54dd4b231a8f1829a8057814f802d93b563e6a Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 5 Aug 2026 22:31:36 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20storageUtil.toPublicObjectUrl=20?= =?UTF-8?q?=EB=AF=B8=EC=8A=A4=ED=85=81=EC=9C=BC=EB=A1=9C=20=EC=9D=B8?= =?UTF-8?q?=ED=95=9C=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20ClothAi?= =?UTF-8?q?=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloth/controller/ClothControllerTest.java | 187 ++++++++++++++ .../cloth/service/ClothServiceTest.java | 239 ++++++++++++++++++ .../controller/HistoryControllerTest.java | 2 +- .../service/HistoryServiceImplTest.java | 6 + 4 files changed, 433 insertions(+), 1 deletion(-) diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java index 334bb08b..79e0c386 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java @@ -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; @@ -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 옷_생성_요청_시 { diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java index 165cd93c..322523a3 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java @@ -18,16 +18,27 @@ import org.clokey.domain.category.repository.CategoryRepository; 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.ClothDetailsResponse; +import org.clokey.domain.cloth.dto.response.ClothDetectAiResponseDTO; +import org.clokey.domain.cloth.dto.response.ClothDetectResponse; import org.clokey.domain.cloth.dto.response.ClothImagesPresignedUrlResponse; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractAiResponseDTO; +import org.clokey.domain.cloth.dto.response.ClothInfoExtractResponse; import org.clokey.domain.cloth.dto.response.ClothListResponse; import org.clokey.domain.cloth.dto.response.ClothRecommendListResponse; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceAiResponseDTO; +import org.clokey.domain.cloth.dto.response.HistoryStyleInferenceResponse; +import org.clokey.domain.cloth.exception.ClothAiErrorCode; import org.clokey.domain.cloth.exception.ClothErrorCode; import org.clokey.domain.cloth.repository.ClothRepository; import org.clokey.domain.coordinate.repository.CoordinateClothRepository; import org.clokey.domain.coordinate.repository.CoordinateRepository; +import org.clokey.domain.history.exception.HistoryErrorCode; import org.clokey.domain.history.repository.HistoryClothTagRepository; import org.clokey.domain.history.repository.HistoryImageRepository; import org.clokey.domain.history.repository.HistoryRepository; @@ -50,6 +61,7 @@ import org.clokey.response.SliceResponse; import org.clokey.util.PresignedUrlResult; import org.clokey.util.StorageUtil; +import org.clokey.util.WebClientUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -63,6 +75,7 @@ import org.springframework.test.context.event.ApplicationEvents; import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; @RecordApplicationEvents class ClothServiceTest extends IntegrationTest { @@ -82,6 +95,7 @@ class ClothServiceTest extends IntegrationTest { @MockitoBean private MemberUtil memberUtil; @MockitoBean private StorageUtil storageUtil; + @MockitoBean private WebClientUtil webClientUtil; @Autowired private ApplicationEvents applicationEvents; @Nested @@ -124,6 +138,227 @@ void setUp() { } } + @Nested + class 옷_정보를_추출할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_옷_정보를_추출한다() { + // given + Category parentCategory = categoryRepository.save(Category.createCategory("상의", null)); + Category category = + categoryRepository.save(Category.createCategory("후드티", parentCategory)); + + ClothInfoExtractRequest request = + new ClothInfoExtractRequest(List.of("testClothImageUrl1")); + + given(storageUtil.doAllFilesExistByUrls(request.clothImageUrls())).willReturn(true); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")); + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); + + ClothInfoExtractAiResponseDTO aiResponse = + new ClothInfoExtractAiResponseDTO( + true, + "success", + List.of( + new ClothInfoExtractAiResponseDTO.ResultItem( + List.of( + new ClothInfoExtractAiResponseDTO.CategoryItem( + category.getId(), "후드티")), + List.of( + new ClothInfoExtractAiResponseDTO.SeasonItem( + 1L, "봄")), + "uploadedTestUrl1")), + null); + given( + webClientUtil.postToAiServer( + any(), any(), eq(ClothInfoExtractAiResponseDTO.class))) + .willReturn(Mono.just(aiResponse)); + + // when + ClothInfoExtractResponse response = clothAiService.extractClothInfo(request); + + // then + assertThat(response.payloads()).hasSize(1); + ClothInfoExtractResponse.Payload payload = response.payloads().get(0); + Assertions.assertAll( + () -> assertThat(payload.clothImageUrl()).isEqualTo("uploadedTestUrl1"), + () -> assertThat(payload.seasons()).containsExactly(Season.SPRING), + () -> assertThat(payload.categoryId()).isEqualTo(category.getId()), + () -> assertThat(payload.categoryName()).isEqualTo("후드티"), + () -> assertThat(payload.parentCategoryId()).isEqualTo(parentCategory.getId()), + () -> assertThat(payload.parentCategoryName()).isEqualTo("상의")); + } + + @Test + void 존재하지_않는_이미지_URL이_포함되면_예외가_발생한다() { + // given + ClothInfoExtractRequest request = + new ClothInfoExtractRequest(List.of("testClothImageUrl1")); + given(storageUtil.doAllFilesExistByUrls(request.clothImageUrls())).willReturn(false); + + // when & then + assertThatThrownBy(() -> clothAiService.extractClothInfo(request)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(ClothErrorCode.ClOTH_NOT_FOUND.getMessage()); + } + + @Test + void AI_서버_응답이_실패면_예외가_발생한다() { + // given + ClothInfoExtractRequest request = + new ClothInfoExtractRequest(List.of("testClothImageUrl1")); + given(storageUtil.doAllFilesExistByUrls(request.clothImageUrls())).willReturn(true); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")); + + ClothInfoExtractAiResponseDTO aiResponse = + new ClothInfoExtractAiResponseDTO(false, "fail", null, "DETECT_EMPTY"); + given( + webClientUtil.postToAiServer( + any(), any(), eq(ClothInfoExtractAiResponseDTO.class))) + .willReturn(Mono.just(aiResponse)); + + // when & then + assertThatThrownBy(() -> clothAiService.extractClothInfo(request)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(ClothAiErrorCode.AI_DETECT_EMPTY.getMessage()); + } + } + + @Nested + class 기록_사진_스타일을_추론할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_스타일을_추론한다() { + // given + given(storageUtil.doesFileExistByUrl(any())).willReturn(true); + + HistoryStyleInferenceAiResponseDTO aiResponse = + new HistoryStyleInferenceAiResponseDTO( + new HistoryStyleInferenceAiResponseDTO.Result( + List.of( + new HistoryStyleInferenceAiResponseDTO.StyleItem( + 1L, "캐주얼")), + List.of( + new HistoryStyleInferenceAiResponseDTO.SituationItem( + 2L, "데일리")))); + given( + webClientUtil.postToAiServer( + any(), any(), eq(HistoryStyleInferenceAiResponseDTO.class))) + .willReturn(Mono.just(aiResponse)); + + HistoryStyleInferenceRequest request = + new HistoryStyleInferenceRequest("testHistoryImageUrl"); + + // when + HistoryStyleInferenceResponse response = clothAiService.inferHistoryStyle(request); + + // then + Assertions.assertAll( + () -> assertThat(response.situationId()).isEqualTo(2L), + () -> assertThat(response.situationName()).isEqualTo("데일리"), + () -> + assertThat(response.styles()) + .extracting("styleId", "styleName") + .containsExactly( + org.assertj.core.groups.Tuple.tuple(1L, "캐주얼"))); + } + + @Test + void 존재하지_않는_이미지_URL이면_예외가_발생한다() { + // given + given(storageUtil.doesFileExistByUrl(any())).willReturn(false); + HistoryStyleInferenceRequest request = + new HistoryStyleInferenceRequest("testHistoryImageUrl"); + + // when & then + assertThatThrownBy(() -> clothAiService.inferHistoryStyle(request)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND.getMessage()); + } + } + + @Nested + class 사진에서_옷을_탐지할_때 { + + @BeforeEach + void setUp() { + Member member = + Member.createMember( + "testEmail", + "testNickName", + OauthInfo.createOauthInfo("testOauthId", OauthProvider.KAKAO)); + memberRepository.save(member); + given(memberUtil.getCurrentMember()).willReturn(member); + } + + @Test + void 유효한_요청이면_옷을_탐지한다() { + // given + given(storageUtil.doesFileExistByUrl(any())).willReturn(true); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")); + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); + + ClothDetectAiResponseDTO aiResponse = + new ClothDetectAiResponseDTO( + true, + "success", + new ClothDetectAiResponseDTO.Result( + 2, 2, List.of("uploadedTestUrl1", "uploadedTestUrl2")), + null); + given(webClientUtil.postToAiServer(any(), any(), eq(ClothDetectAiResponseDTO.class))) + .willReturn(Mono.just(aiResponse)); + + ClothDetectRequest request = new ClothDetectRequest("testImageUrl"); + + // when + ClothDetectResponse response = clothAiService.detectClothes(request); + + // then + assertThat(response.payloads()) + .extracting("clothImageUrl") + .containsExactly("uploadedTestUrl1", "uploadedTestUrl2"); + } + + @Test + void 존재하지_않는_이미지_URL이면_예외가_발생한다() { + // given + given(storageUtil.doesFileExistByUrl(any())).willReturn(false); + ClothDetectRequest request = new ClothDetectRequest("testImageUrl"); + + // when & then + assertThatThrownBy(() -> clothAiService.detectClothes(request)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND.getMessage()); + } + } + @Nested @Transactional class 옷을_생성할_때 { @@ -138,6 +373,8 @@ void setUp() { memberRepository.save(member); given(memberUtil.getCurrentMember()).willReturn(member); + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); Category parentCategory = Category.createCategory("testParentCategory", null); Category category = Category.createCategory("testCategory", parentCategory); @@ -670,6 +907,8 @@ void setUp() { OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); memberRepository.saveAll(List.of(member1, member2)); given(memberUtil.getCurrentMember()).willReturn(member1); + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); Category parentCategory = Category.createCategory("testParentCategory", null); Category category = Category.createCategory("testCategory", parentCategory); diff --git a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java index 155e4322..5f16cf69 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java @@ -934,7 +934,7 @@ class 월별_기록_조회_요청_시 { class 기록_소유_여부_확인_요청_시 { @Test - void testCheckHistoryOwnership() throws Exception { + void 유효한_요청이면_소유_여부를_반환한다() throws Exception { // given HistoryOwnershipCheckResponse testHistoryOwnershipCheckResponse = HistoryOwnershipCheckResponse.of(true); diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index eb4e5d65..21aa7793 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -128,6 +128,9 @@ class 기록을_생성할_때 { @BeforeEach void setUp() { + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); + Member member1 = Member.createMember( "testEmail1", @@ -417,6 +420,9 @@ class 기록을_수정할_때 { @BeforeEach void setUp() { + given(storageUtil.toPublicObjectUrl(any())) + .willAnswer(invocation -> invocation.getArgument(0)); + Member member1 = Member.createMember( "testEmail1",