From 703b50e15e41dd3f6a22560d9f6f30351aa96500 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 30 Mar 2026 10:48:26 +0900 Subject: [PATCH 01/31] chore: update environment variables configuration --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index f4d9daf9b..ef0bc1e83 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit f4d9daf9b509735fa9fe759191141530f1750468 +Subproject commit ef0bc1e83e63721f8617944b4d085d304be78abb From ec35d585f76db5436d7bc859dd71ab93d3e84ba6 Mon Sep 17 00:00:00 2001 From: rlagu Date: Mon, 30 Mar 2026 23:14:33 +0900 Subject: [PATCH 02/31] =?UTF-8?q?refactor:=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20MockMvc=EC=97=90=EC=84=9C=20MockM?= =?UTF-8?q?vcTester=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20(8=EA=B0=9C=20=ED=8C=8C=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- bottlenote-admin-api/VERSION | 2 +- bottlenote-product-api/VERSION | 2 +- .../integration/BannerIntegrationTest.java | 87 +++---- .../UserHistoryIntegrationTest.java | 206 ++++++++-------- .../integration/LikesIntegrationTest.java | 90 +++---- .../integration/PicksIntegrationTest.java | 90 +++---- .../integration/RatingIntegrationTest.java | 93 +++----- .../ReviewReplyIntegrationTest.java | 221 ++++++++---------- .../BusinessSupportIntegrationTest.java | 124 +++++----- git.environment-variables | 2 +- plan/{ => complete}/banner-media-type.md | 21 ++ .../presigned-url-content-type.md | 20 ++ 12 files changed, 438 insertions(+), 520 deletions(-) rename plan/{ => complete}/banner-media-type.md (71%) rename plan/{ => complete}/presigned-url-content-type.md (72%) diff --git a/bottlenote-admin-api/VERSION b/bottlenote-admin-api/VERSION index 7ee7020b3..59e9e6049 100644 --- a/bottlenote-admin-api/VERSION +++ b/bottlenote-admin-api/VERSION @@ -1 +1 @@ -1.0.10 +1.0.11 diff --git a/bottlenote-product-api/VERSION b/bottlenote-product-api/VERSION index 66c4c2263..7ee7020b3 100644 --- a/bottlenote-product-api/VERSION +++ b/bottlenote-product-api/VERSION @@ -1 +1 @@ -1.0.9 +1.0.10 diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java index f51f16c50..e7776782a 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/banner/integration/BannerIntegrationTest.java @@ -2,14 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.http.MediaType.APPLICATION_JSON; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.banner.dto.response.BannerResponse; import app.bottlenote.banner.fixture.BannerTestFactory; +import app.bottlenote.global.data.response.GlobalResponse; import com.fasterxml.jackson.core.type.TypeReference; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -17,9 +15,8 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] BannerQueryController") @@ -38,14 +35,13 @@ void test_1() throws Exception { // given bannerTestFactory.persistMultipleBanners(5); - // when & then - mockMvc - .perform(get("/api/v1/banners").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(5)); + // when + MvcTestResult result = + mockMvcTester.get().uri("/api/v1/banners").contentType(APPLICATION_JSON).exchange(); + + // then + List banners = extractDataAsList(result, new TypeReference<>() {}); + assertEquals(5, banners.size()); } @DisplayName("limit 파라미터로 조회 개수를 제한할 수 있다.") @@ -54,15 +50,18 @@ void test_2() throws Exception { // given bannerTestFactory.persistMultipleBanners(5); - // when & then - mockMvc - .perform( - get("/api/v1/banners").param("limit", "3").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(3)); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/banners") + .param("limit", "3") + .contentType(APPLICATION_JSON) + .exchange(); + + // then + List banners = extractDataAsList(result, new TypeReference<>() {}); + assertEquals(3, banners.size()); } @DisplayName("배너는 sortOrder 기준으로 오름차순 정렬된다.") @@ -92,12 +91,8 @@ void test_3() throws Exception { true); // when - MvcResult result = - mockMvc - .perform(get("/api/v1/banners").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn(); + MvcTestResult result = + mockMvcTester.get().uri("/api/v1/banners").contentType(APPLICATION_JSON).exchange(); // then List banners = extractDataAsList(result, new TypeReference<>() {}); @@ -113,14 +108,13 @@ void test_4() throws Exception { // given bannerTestFactory.persistMixedActiveBanners(3, 2); - // when & then - mockMvc - .perform(get("/api/v1/banners").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andExpect(jsonPath("$.data.length()").value(3)); + // when + MvcTestResult result = + mockMvcTester.get().uri("/api/v1/banners").contentType(APPLICATION_JSON).exchange(); + + // then + List banners = extractDataAsList(result, new TypeReference<>() {}); + assertEquals(3, banners.size()); } @DisplayName("활성 배너가 없으면 빈 배열을 반환한다.") @@ -128,26 +122,21 @@ void test_4() throws Exception { void test_5() throws Exception { // given - 데이터 없음 - // when & then - MvcResult result = - mockMvc - .perform(get("/api/v1/banners").contentType(MediaType.APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").isArray()) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester.get().uri("/api/v1/banners").contentType(APPLICATION_JSON).exchange(); + // then List banners = extractDataAsList(result, new TypeReference<>() {}); assertTrue(banners.isEmpty()); } } - private List extractDataAsList(MvcResult result, TypeReference> typeRef) + private List extractDataAsList(MvcTestResult result, TypeReference> typeRef) throws Exception { + result.assertThat().hasStatusOk(); String responseString = result.getResponse().getContentAsString(); - var response = - mapper.readValue(responseString, app.bottlenote.global.data.response.GlobalResponse.class); + GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); return mapper.convertValue(response.getData(), typeRef); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java index bbc665401..7b785a1d1 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/history/integration/UserHistoryIntegrationTest.java @@ -1,19 +1,17 @@ package app.bottlenote.history.integration; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.common.fixture.HistoryTestData; import app.bottlenote.common.fixture.TestDataSetupHelper; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.service.cursor.SortOrder; import app.bottlenote.history.dto.response.UserHistoryItem; import app.bottlenote.history.dto.response.UserHistorySearchResponse; import app.bottlenote.picks.constant.PicksStatus; import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; -import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.Assertions; @@ -21,8 +19,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [history] UserHistory") @@ -38,23 +35,20 @@ void test_1() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult result = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - System.out.println("contentAsString = " + contentAsString); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse userHistorySearchResponse = - mapper.convertValue(response.getData(), UserHistorySearchResponse.class); + extractData(result, UserHistorySearchResponse.class); - // when & then Assertions.assertNotNull(userHistorySearchResponse); Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } @@ -67,23 +61,21 @@ void test_2() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult result = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .param("sortOrder", SortOrder.DESC.name()) - .with(csrf())) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .param("sortOrder", SortOrder.DESC.name()) + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse userHistorySearchResponse = - mapper.convertValue(response.getData(), UserHistorySearchResponse.class); + extractData(result, UserHistorySearchResponse.class); - // when & then Assertions.assertNotNull(userHistorySearchResponse); Assertions.assertNotNull(userHistorySearchResponse.userHistories()); Assertions.assertFalse(userHistorySearchResponse.userHistories().isEmpty()); @@ -106,22 +98,17 @@ void test_3() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult initialMvcResult = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andReturn(); - - String initialResponseString = - initialMvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse initialGlobalResponse = - mapper.readValue(initialResponseString, GlobalResponse.class); + MvcTestResult initialResult = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); UserHistorySearchResponse initialUserHistoryResponse = - mapper.convertValue(initialGlobalResponse.getData(), UserHistorySearchResponse.class); + extractData(initialResult, UserHistorySearchResponse.class); List createdAtList = initialUserHistoryResponse.userHistories().stream() @@ -129,24 +116,21 @@ void test_3() throws Exception { .sorted() .toList(); - MvcResult filteredMvcResult = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .param("startDate", createdAtList.get(0).toString()) - .param("endDate", createdAtList.get(1).toString()) - .with(csrf())) - .andReturn(); - - String filteredResponseString = - filteredMvcResult.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse filteredGlobalResponse = - mapper.readValue(filteredResponseString, GlobalResponse.class); - + // when + MvcTestResult filteredResult = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .param("startDate", createdAtList.get(0).toString()) + .param("endDate", createdAtList.get(1).toString()) + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse filteredUserHistoryResponse = - mapper.convertValue(filteredGlobalResponse.getData(), UserHistorySearchResponse.class); + extractData(filteredResult, UserHistorySearchResponse.class); Assertions.assertNotNull(filteredUserHistoryResponse); List userHistoryItems = filteredUserHistoryResponse.userHistories(); @@ -170,23 +154,21 @@ void test_4() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult result = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .param("ratingPoint", "5") - .with(csrf())) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .param("ratingPoint", "5") + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse userHistorySearchResponse = - mapper.convertValue(response.getData(), UserHistorySearchResponse.class); + extractData(result, UserHistorySearchResponse.class); - // when & then Assertions.assertNotNull(userHistorySearchResponse); Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } @@ -199,24 +181,22 @@ void test_5() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult result = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .param("picksStatus", PicksStatus.PICK.name()) - .param("picksStatus", PicksStatus.UNPICK.name()) - .with(csrf())) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .param("picksStatus", PicksStatus.PICK.name()) + .param("picksStatus", PicksStatus.UNPICK.name()) + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse userHistorySearchResponse = - mapper.convertValue(response.getData(), UserHistorySearchResponse.class); + extractData(result, UserHistorySearchResponse.class); - // when & then Assertions.assertNotNull(userHistorySearchResponse); Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } @@ -229,25 +209,23 @@ void test_6() throws Exception { User targetUser = data.getUser(0); TokenItem token = getToken(targetUser); - MvcResult result = - mockMvc - .perform( - get("/api/v1/history/{targetUserId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .param("historyReviewFilterType", "BEST_REVIEW") - .param("historyReviewFilterType", "REVIEW_LIKE") - .param("historyReviewFilterType", "REVIEW_REPLY") - .with(csrf())) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/history/{targetUserId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .param("historyReviewFilterType", "BEST_REVIEW") + .param("historyReviewFilterType", "REVIEW_LIKE") + .param("historyReviewFilterType", "REVIEW_REPLY") + .with(csrf()) + .exchange(); + + // then UserHistorySearchResponse userHistorySearchResponse = - mapper.convertValue(response.getData(), UserHistorySearchResponse.class); + extractData(result, UserHistorySearchResponse.class); - // when & then Assertions.assertNotNull(userHistorySearchResponse); Assertions.assertNotNull(userHistorySearchResponse.userHistories()); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java index ac2df9390..fc797e785 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/like/integration/LikesIntegrationTest.java @@ -4,16 +4,12 @@ import static app.bottlenote.like.dto.response.LikesUpdateResponse.Message.LIKED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.like.constant.LikeStatus; import app.bottlenote.like.domain.Likes; import app.bottlenote.like.domain.LikesRepository; @@ -24,13 +20,11 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] LikesController") @@ -53,26 +47,19 @@ void test_1() throws Exception { LikesUpdateRequest likesUpdateRequest = new LikesUpdateRequest(review.getId(), LikeStatus.LIKE); - // When & Then - MvcResult result = - mockMvc - .perform( - put("/api/v1/likes") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(likesUpdateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - LikesUpdateResponse likesUpdateResponse = - mapper.convertValue(response.getData(), LikesUpdateResponse.class); + // When + MvcTestResult result = + mockMvcTester + .put() + .uri("/api/v1/likes") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(likesUpdateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + // Then + LikesUpdateResponse likesUpdateResponse = extractData(result, LikesUpdateResponse.class); assertEquals(likesUpdateResponse.message(), LIKED.getMessage()); } @@ -91,17 +78,17 @@ void test_2() throws Exception { new LikesUpdateRequest(review.getId(), LikeStatus.DISLIKE); // When - 좋아요 등록 - mockMvc - .perform( - put("/api/v1/likes") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(likesUpdateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); + MvcTestResult registerResult = + mockMvcTester + .put() + .uri("/api/v1/likes") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(likesUpdateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + registerResult.assertThat().hasStatusOk(); Likes likes = likesRepository.findByReviewIdAndUserId(review.getId(), likeUser.getId()).orElse(null); @@ -109,25 +96,18 @@ void test_2() throws Exception { assertEquals(LikeStatus.LIKE, likes.getStatus()); // When - 좋아요 해제 - MvcResult result = - mockMvc - .perform( - put("/api/v1/likes") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(dislikesUpdateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .put() + .uri("/api/v1/likes") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(dislikesUpdateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); // Then - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - LikesUpdateResponse likesUpdateResponse = - mapper.convertValue(response.getData(), LikesUpdateResponse.class); + LikesUpdateResponse likesUpdateResponse = extractData(result, LikesUpdateResponse.class); assertEquals(likesUpdateResponse.message(), DISLIKE.getMessage()); Likes dislike = diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java index 8462af7bc..95dc626e0 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/picks/integration/PicksIntegrationTest.java @@ -6,16 +6,12 @@ import static app.bottlenote.picks.dto.response.PicksUpdateResponse.Message.UNPICKED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.picks.domain.Picks; import app.bottlenote.picks.domain.PicksRepository; import app.bottlenote.picks.dto.request.PicksUpdateRequest; @@ -23,13 +19,11 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] PickController") @@ -49,26 +43,19 @@ void test_1() throws Exception { PicksUpdateRequest picksUpdateRequest = new PicksUpdateRequest(alcohol.getId(), PICK); - // When & Then - MvcResult result = - mockMvc - .perform( - put("/api/v1/picks") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(picksUpdateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - PicksUpdateResponse picksUpdateResponse = - mapper.convertValue(response.getData(), PicksUpdateResponse.class); + // When + MvcTestResult result = + mockMvcTester + .put() + .uri("/api/v1/picks") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(picksUpdateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + // Then + PicksUpdateResponse picksUpdateResponse = extractData(result, PicksUpdateResponse.class); assertEquals(picksUpdateResponse.message(), PICKED.message()); } @@ -84,17 +71,17 @@ void test_2() throws Exception { PicksUpdateRequest unregisterPicksRequest = new PicksUpdateRequest(alcohol.getId(), UNPICK); // When - 찜 등록 - mockMvc - .perform( - put("/api/v1/picks") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(registerPicksRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); + MvcTestResult registerResult = + mockMvcTester + .put() + .uri("/api/v1/picks") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(registerPicksRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + registerResult.assertThat().hasStatusOk(); Picks picks = picksRepository.findByAlcoholIdAndUserId(alcohol.getId(), user.getId()).orElse(null); @@ -102,25 +89,18 @@ void test_2() throws Exception { assertEquals(PICK, picks.getStatus()); // When - 찜 해제 - MvcResult result = - mockMvc - .perform( - put("/api/v1/picks") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(unregisterPicksRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .put() + .uri("/api/v1/picks") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(unregisterPicksRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); // Then - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - PicksUpdateResponse picksUpdateResponse = - mapper.convertValue(response.getData(), PicksUpdateResponse.class); + PicksUpdateResponse picksUpdateResponse = extractData(result, PicksUpdateResponse.class); assertEquals(picksUpdateResponse.message(), UNPICKED.message()); Picks unPick = diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java index 3e1151d98..37c328124 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/rating/integration/RatingIntegrationTest.java @@ -2,17 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.rating.dto.request.RatingRegisterRequest; import app.bottlenote.rating.dto.response.RatingListFetchResponse; import app.bottlenote.rating.dto.response.RatingRegisterResponse; @@ -22,15 +17,13 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] RatingController") @@ -50,25 +43,20 @@ void test_1() throws Exception { RatingRegisterRequest ratingRegisterRequest = new RatingRegisterRequest(alcohol.getId(), 3.0); - // When & Then - MvcResult result = - mockMvc - .perform( - post("/api/v1/rating/register") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(ratingRegisterRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); + // When + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/rating/register") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(ratingRegisterRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // Then RatingRegisterResponse ratingRegisterResponse = - mapper.convertValue(response.getData(), RatingRegisterResponse.class); + extractData(result, RatingRegisterResponse.class); assertEquals(ratingRegisterRequest.rating().toString(), ratingRegisterResponse.rating()); assertEquals(Message.SUCCESS.getMessage(), ratingRegisterResponse.message()); @@ -85,31 +73,24 @@ void test_2() throws Exception { } TokenItem token = getToken(user); - // 각 알코올에 별점 등록 for (int i = 0; i < alcohols.size(); i++) { int ratingPoint = (i % 5) + 1; ratingTestFactory.persistRating(user, alcohols.get(i), ratingPoint); } // When - MvcResult result = - mockMvc - .perform( - get("/api/v1/rating") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/rating") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); // Then - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); RatingListFetchResponse ratingListFetchResponse = - mapper.convertValue(response.getData(), RatingListFetchResponse.class); + extractData(result, RatingListFetchResponse.class); assertEquals(alcohols.size(), ratingListFetchResponse.totalCount()); } @@ -122,28 +103,20 @@ void test_3() throws Exception { Alcohol alcohol = alcoholTestFactory.persistAlcohol(); TokenItem token = getToken(user); - // 별점 1점 등록 ratingTestFactory.persistRating(user, alcohol, 1); // When - MvcResult result = - mockMvc - .perform( - get("/api/v1/rating/{alcoholId}", alcohol.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/rating/{alcoholId}", alcohol.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); // Then - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - UserRatingResponse userRatingResponse = - mapper.convertValue(response.getData(), UserRatingResponse.class); + UserRatingResponse userRatingResponse = extractData(result, UserRatingResponse.class); assertNotNull(userRatingResponse); assertEquals(1.0, userRatingResponse.rating()); diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java index e940533cf..dca3d24e2 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewReplyIntegrationTest.java @@ -2,18 +2,12 @@ import static app.bottlenote.review.constant.ReviewReplyStatus.DELETED; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.review.constant.ReviewReplyResultMessage; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewReply; @@ -26,14 +20,12 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] ReviewReplyController") @@ -61,25 +53,19 @@ void test_1() throws Exception { ReviewReplyRegisterRequest replyRegisterRequest = new ReviewReplyRegisterRequest("댓글 내용", null); - MvcResult result = - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewReplyResponse reviewReplyResponse = - mapper.convertValue(response.getData(), ReviewReplyResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/review/reply/register/{reviewId}", review.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(replyRegisterRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + ReviewReplyResponse reviewReplyResponse = extractData(result, ReviewReplyResponse.class); assertEquals( ReviewReplyResultMessage.SUCCESS_REGISTER_REPLY, reviewReplyResponse.codeMessage()); } @@ -98,25 +84,19 @@ void test_2() throws Exception { ReviewReplyRegisterRequest replyRegisterRequest = new ReviewReplyRegisterRequest("대댓글 내용", parentReply.getId()); - MvcResult result = - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewReplyResponse reviewReplyResponse = - mapper.convertValue(response.getData(), ReviewReplyResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/review/reply/register/{reviewId}", review.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(replyRegisterRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + ReviewReplyResponse reviewReplyResponse = extractData(result, ReviewReplyResponse.class); assertEquals( ReviewReplyResultMessage.SUCCESS_REGISTER_REPLY, reviewReplyResponse.codeMessage()); } @@ -138,24 +118,21 @@ void test_1() throws Exception { reviewTestFactory.persistReviewReply(review, replyAuthor1); reviewTestFactory.persistReviewReply(review, replyAuthor2); - // when && then - MvcResult result = - mockMvc - .perform( - get("/api/v1/review/reply/{reviewId}", review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .param("cursor", "0") - .param("pageSize", "50") - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.length()").value(2)) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - log.info("responseString : {}", responseString); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/review/reply/{reviewId}", review.getId()) + .contentType(APPLICATION_JSON) + .param("cursor", "0") + .param("pageSize", "50") + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatusOk(); + RootReviewReplyResponse rootResponse = extractData(result, RootReviewReplyResponse.class); + assertEquals(2, rootResponse.totalCount()); } @DisplayName("리뷰의 대댓글 목록을 조회할 수 있다.") @@ -174,41 +151,36 @@ void test_2() throws Exception { final int count = 2; for (int i = 0; i < count; i++) { - mockMvc - .perform( - post("/api/v1/review/reply/register/{reviewId}", review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); + MvcTestResult registerResult = + mockMvcTester + .post() + .uri("/api/v1/review/reply/register/{reviewId}", review.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(replyRegisterRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + registerResult.assertThat().hasStatusOk(); } - MvcResult result = - mockMvc - .perform( - get( - "/api/v1/review/reply/{reviewId}/sub/{rootReplyId}", - review.getId(), - parentReply.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(replyRegisterRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri( + "/api/v1/review/reply/{reviewId}/sub/{rootReplyId}", + review.getId(), + parentReply.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(replyRegisterRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then SubReviewReplyResponse subReviewReplyResponse = - mapper.convertValue(globalResponse.getData(), SubReviewReplyResponse.class); - + extractData(result, SubReviewReplyResponse.class); assertEquals(count, subReviewReplyResponse.totalCount()); } } @@ -230,37 +202,32 @@ void test_1() throws Exception { reviewTestFactory.persistReviewReply(review, replyAuthor2); TokenItem token = getToken(replyAuthor1); - mockMvc - .perform( - delete( - "/api/v1/review/reply/{reviewId}/{replyId}", - review.getId(), - replyToDelete.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); - - MvcResult result = - mockMvc - .perform( - get("/api/v1/review/reply/{reviewId}", review.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.length()").value(2)) - .andReturn(); + // when - 삭제 + MvcTestResult deleteResult = + mockMvcTester + .delete() + .uri( + "/api/v1/review/reply/{reviewId}/{replyId}", + review.getId(), + replyToDelete.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + deleteResult.assertThat().hasStatusOk(); + + // then - 조회하여 삭제 상태 확인 + MvcTestResult listResult = + mockMvcTester + .get() + .uri("/api/v1/review/reply/{reviewId}", review.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); RootReviewReplyResponse rootReviewReplyResponse = - mapper.convertValue(globalResponse.getData(), RootReviewReplyResponse.class); + extractData(listResult, RootReviewReplyResponse.class); assertEquals( rootReviewReplyResponse.reviewReplies().get(0).reviewReplyContent(), diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java index 1b18e4022..311b3d0a9 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/business/integration/BusinessSupportIntegrationTest.java @@ -2,13 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.support.business.constant.BusinessSupportType; @@ -23,6 +17,8 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] BusinessSupportController") @@ -39,17 +35,17 @@ void register() throws Exception { new BusinessSupportUpsertRequest( "이벤트 협업 관련 문의드려요", "blah blah", "test@naver.com", BusinessSupportType.EVENT, List.of()); - mockMvc - .perform( - post("/api/v1/business-support") - .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsBytes(req)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn(); - + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/business-support") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(req)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + result.assertThat().hasStatusOk(); assertEquals(1, repository.findAll().size()); } @@ -62,14 +58,16 @@ void register_fail_unauthorized() throws Exception { "이벤트 협업 관련 문의드려요", "blah blah", "test@naver.com", BusinessSupportType.EVENT, List.of()); // when & then - mockMvc - .perform( - post("/api/v1/business-support") - .with(csrf()) - .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(req))) - .andDo(print()) - .andExpect(status().isBadRequest()); + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/business-support") + .with(csrf()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(req)) + .exchange(); + + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); } @Test @@ -80,10 +78,14 @@ void get_list_success() throws Exception { businessFactory.persist(user.getId()); // when & then - mockMvc - .perform(get("/api/v1/business-support").header("Authorization", "Bearer " + getToken())) - .andDo(print()) - .andExpect(status().isOk()); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/business-support") + .header("Authorization", "Bearer " + getToken()) + .exchange(); + + result.assertThat().hasStatusOk(); } @Test @@ -94,12 +96,14 @@ void get_detail_success() throws Exception { BusinessSupport support = businessFactory.persist(user.getId()); // when & then - mockMvc - .perform( - get("/api/v1/business-support/{id}", support.getId()) - .header("Authorization", "Bearer " + getToken())) - .andDo(print()) - .andExpect(status().isOk()); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/business-support/{id}", support.getId()) + .header("Authorization", "Bearer " + getToken()) + .exchange(); + + result.assertThat().hasStatusOk(); } @Test @@ -109,12 +113,14 @@ void get_detail_fail_not_found() throws Exception { long nonExistId = 999L; // when & then - mockMvc - .perform( - get("/api/v1/business-support/{id}", nonExistId) - .header("Authorization", "Bearer " + getToken())) - .andDo(print()) - .andExpect(status().isBadRequest()); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/business-support/{id}", nonExistId) + .header("Authorization", "Bearer " + getToken()) + .exchange(); + + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); } @Test @@ -128,15 +134,17 @@ void modify_success() throws Exception { "이벤트 협업 관련 문의드려요", "blah blah", "test@naver.com", BusinessSupportType.EVENT, List.of()); // when & then - mockMvc - .perform( - patch("/api/v1/business-support/{id}", support.getId()) - .with(csrf()) - .header("Authorization", "Bearer " + getToken()) - .contentType(APPLICATION_JSON) - .content(mapper.writeValueAsString(req))) - .andDo(print()) - .andExpect(status().isOk()); + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/business-support/{id}", support.getId()) + .with(csrf()) + .header("Authorization", "Bearer " + getToken()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(req)) + .exchange(); + + result.assertThat().hasStatusOk(); } @Test @@ -147,12 +155,14 @@ void delete_success() throws Exception { BusinessSupport support = businessFactory.persist(user.getId()); // when & then - mockMvc - .perform( - delete("/api/v1/business-support/{id}", support.getId()) - .with(csrf()) - .header("Authorization", "Bearer " + getToken())) - .andDo(print()) - .andExpect(status().isOk()); + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/business-support/{id}", support.getId()) + .with(csrf()) + .header("Authorization", "Bearer " + getToken()) + .exchange(); + + result.assertThat().hasStatusOk(); } } diff --git a/git.environment-variables b/git.environment-variables index ef0bc1e83..a3fa81fe4 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit ef0bc1e83e63721f8617944b4d085d304be78abb +Subproject commit a3fa81fe4c0fa2fcf3017194637e54083c66caba diff --git a/plan/banner-media-type.md b/plan/complete/banner-media-type.md similarity index 71% rename from plan/banner-media-type.md rename to plan/complete/banner-media-type.md index 7c894a300..288f48bc2 100644 --- a/plan/banner-media-type.md +++ b/plan/complete/banner-media-type.md @@ -1,3 +1,24 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-03-30 + +** Core Achievements ** +- MediaType enum(IMAGE, VIDEO) 생성 및 Banner 엔티티에 mediaType 필드 추가 +- 배너 등록/수정/조회 API에 mediaType 파라미터 반영 완료 +- 기존 데이터 호환: 기본값 IMAGE, update() 시 null이면 기존 값 유지 + +** Key Components ** +- MediaType.java: bottlenote-mono/.../banner/constant/MediaType.java +- Banner.java: mediaType 필드 + update() 파라미터 추가 +- AdminBannerCreateRequest/UpdateRequest: mediaType 필드 추가 +- BannerResponse, AdminBannerDetailResponse: 응답에 mediaType 포함 +- AdminBannerService: create/update/getDetail 3개 메서드 반영 +================================================================================ +``` + # Banner mediaType 필드 추가 > Issue: https://github.com/bottle-note/workspace/issues/205 diff --git a/plan/presigned-url-content-type.md b/plan/complete/presigned-url-content-type.md similarity index 72% rename from plan/presigned-url-content-type.md rename to plan/complete/presigned-url-content-type.md index a761df7f2..54db2bc3e 100644 --- a/plan/presigned-url-content-type.md +++ b/plan/complete/presigned-url-content-type.md @@ -1,3 +1,23 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-03-30 + +** Core Achievements ** +- PreSignUrlProvider의 하드코딩 확장자(.jpg) 제거, 7개 MIME 타입 동적 지원 +- ImageUploadService에 withContentType 적용으로 S3 Content-Type 제어 가능 +- ImageUploadRequest에 contentType 필드 추가 (기본값 image/jpeg) + +** Key Components ** +- PreSignUrlProvider.java: ALLOWED_CONTENT_TYPES 맵 (jpg, png, webp, gif, svg, mp4, pdf) +- ImageUploadService.java: generatePreSignUrl(imageKey, contentType) 시그니처 변경 +- ImageUploadRequest.java: contentType 필드 추가 +- FileExceptionCode.java: UNSUPPORTED_CONTENT_TYPE 예외 추가 +================================================================================ +``` + # PreSigned URL Content-Type 지원 > Issue: https://github.com/bottle-note/workspace/issues/205 From 44f000cc77cecd10074de249a532af96d03c7cf1 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 31 Mar 2026 00:36:57 +0900 Subject: [PATCH 03/31] =?UTF-8?q?refactor:=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20MockMvc=EC=97=90=EC=84=9C=20MockM?= =?UTF-8?q?vcTester=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20(5=EA=B0=9C=20=ED=8C=8C=EC=9D=BC,=20Batch?= =?UTF-8?q?=205-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../integration/ReviewIntegrationTest.java | 391 ++++++++---------- .../help/integration/HelpIntegrationTest.java | 383 +++++++++-------- .../integration/ReportIntegrationTest.java | 170 ++++---- .../UserCommandIntegrationTest.java | 239 +++++------ .../integration/UserQueryIntegrationTest.java | 303 +++++++------- 5 files changed, 705 insertions(+), 781 deletions(-) diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java index 19dbe790b..526bf1227 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/review/integration/ReviewIntegrationTest.java @@ -1,24 +1,16 @@ package app.bottlenote.review.integration; -import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.global.exception.custom.code.ValidExceptionCode; import app.bottlenote.review.constant.ReviewDisplayStatus; import app.bottlenote.review.constant.ReviewResultMessage; @@ -36,7 +28,6 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -44,8 +35,8 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] ReviewReplyController") @@ -70,25 +61,18 @@ void test_1() throws Exception { reviewTestFactory.persistReview(user, alcohol); // when - MvcResult result = - mockMvc - .perform( - get("/api/v1/reviews/{alcoholId}", alcohol.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewListResponse reviewListResponse = - mapper.convertValue(response.getData(), ReviewListResponse.class); - List reviewInfos = reviewListResponse.reviewList(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/reviews/{alcoholId}", alcohol.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); // then + ReviewListResponse reviewListResponse = extractData(result, ReviewListResponse.class); + List reviewInfos = reviewListResponse.reviewList(); + assertNotNull(reviewListResponse); assertFalse(reviewInfos.isEmpty()); } @@ -103,42 +87,34 @@ void test_2() throws Exception { ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); + + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(reviewCreateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + ReviewCreateResponse reviewCreateResponse = - mapper.convertValue(response.getData(), ReviewCreateResponse.class); + extractData(createResult, ReviewCreateResponse.class); final Long reviewId = reviewCreateResponse.getId(); - MvcResult result2 = - mockMvc - .perform( - get("/api/v1/reviews/detail/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString2 = result2.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response2 = mapper.readValue(contentAsString2, GlobalResponse.class); + + // when + MvcTestResult detailResult = + mockMvcTester + .get() + .uri("/api/v1/reviews/detail/{reviewId}", reviewId) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // then ReviewDetailResponse reviewDetailResponse = - mapper.convertValue(response2.getData(), ReviewDetailResponse.class); + extractData(detailResult, ReviewDetailResponse.class); assertNotNull(reviewDetailResponse.reviewInfo()); reviewDetailResponse.reviewImageList().forEach(Assertions::assertNotNull); @@ -158,23 +134,18 @@ void test_3() throws Exception { Review review = reviewTestFactory.persistReview(user, alcohol); List reviewList = reviewRepository.findByUserId(user.getId()); - MvcResult result = - mockMvc - .perform( - get("/api/v1/reviews/me/{alcoholId}", alcohol.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewListResponse reviewListResponse = - mapper.convertValue(response.getData(), ReviewListResponse.class); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/reviews/me/{alcoholId}", alcohol.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + ReviewListResponse reviewListResponse = extractData(result, ReviewListResponse.class); assertNotNull(reviewListResponse.reviewList()); assertEquals(reviewList.size(), reviewListResponse.reviewList().size()); @@ -206,24 +177,19 @@ void test_1() throws Exception { ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewCreateResponse reviewCreateResponse = - mapper.convertValue(response.getData(), ReviewCreateResponse.class); + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(reviewCreateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + ReviewCreateResponse reviewCreateResponse = extractData(result, ReviewCreateResponse.class); assertEquals(reviewCreateRequest.content(), reviewCreateResponse.getContent()); } @@ -244,62 +210,46 @@ void test_1() throws Exception { ReviewCreateRequest reviewCreateRequest = ReviewObjectFixture.getReviewCreateRequestWithAlcoholId(alcohol.getId()); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(reviewCreateRequest)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); + // 리뷰 생성 + MvcTestResult createResult = + mockMvcTester + .post() + .uri("/api/v1/reviews") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(reviewCreateRequest)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + ReviewCreateResponse reviewCreateResponse = - mapper.convertValue(response.getData(), ReviewCreateResponse.class); + extractData(createResult, ReviewCreateResponse.class); final Long alcoholId = reviewCreateRequest.alcoholId(); final Long reviewId = reviewCreateResponse.getId(); - // 생성한 리뷰 삭제 - MvcResult result2 = - mockMvc - .perform( - delete("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - String contentAsString2 = result2.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response2 = mapper.readValue(contentAsString2, GlobalResponse.class); + // 리뷰 삭제 + MvcTestResult deleteResult = + mockMvcTester + .delete() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + ReviewResultResponse reviewResultResponse = - mapper.convertValue(response2.getData(), ReviewResultResponse.class); + extractData(deleteResult, ReviewResultResponse.class); // 리뷰 목록에서 조회안되는지 검증 - MvcResult result3 = - mockMvc - .perform( - get("/api/v1/reviews/{alcoholId}", alcoholId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString3 = result3.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response3 = mapper.readValue(contentAsString3, GlobalResponse.class); - ReviewListResponse reviewListResponse = - mapper.convertValue(response3.getData(), ReviewListResponse.class); + MvcTestResult listResult = + mockMvcTester + .get() + .uri("/api/v1/reviews/{alcoholId}", alcoholId) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + ReviewListResponse reviewListResponse = extractData(listResult, ReviewListResponse.class); List reviewInfos = reviewListResponse.reviewList(); assertEquals( @@ -325,18 +275,19 @@ void test_1() throws Exception { final ReviewModifyRequest request = ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PUBLIC); - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatusOk(); Review savedReview = reviewRepository.findById(reviewId).orElseThrow(); assertEquals(savedReview.getContent(), request.content()); } @@ -354,21 +305,20 @@ void test_2() throws Exception { final ReviewModifyRequest request = ReviewObjectFixture.getNullableReviewModifyRequest(ReviewDisplayStatus.PRIVATE); - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + // then + result.assertThat().hasStatusOk(); Review savedReview = reviewRepository.findById(reviewId).orElseThrow(); - assertEquals(savedReview.getContent(), request.content()); } @@ -387,29 +337,20 @@ void test_3() throws Exception { final Long reviewId = review.getId(); final ReviewModifyRequest request = ReviewObjectFixture.getWrongReviewModifyRequest(); - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andExpect(status().isBadRequest()) - .andDo(print()) - .andExpect(jsonPath("$.errors", hasSize(2))) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_CONTENT_REQUIRED')].status") - .value(notNullEmpty.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_CONTENT_REQUIRED')].message") - .value(notNullEmpty.message())) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_DISPLAY_STATUS_NOT_EMPTY')].status") - .value(notStatusEmpty.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_DISPLAY_STATUS_NOT_EMPTY')].message") - .value(notStatusEmpty.message())) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}", reviewId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result.assertThat().bodyJson().extractingPath("$.errors").asArray().hasSize(2); } @DisplayName("리뷰 상태 변경에 성공한다.") @@ -425,18 +366,19 @@ void test_4() throws Exception { final ReviewModifyRequest request = ReviewObjectFixture.getReviewModifyRequest(ReviewDisplayStatus.PRIVATE); - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}/display", reviewId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatusOk(); Review savedReview = reviewRepository.findById(reviewId).orElseThrow(); assertEquals(ReviewDisplayStatus.PRIVATE, savedReview.getStatus()); } @@ -455,23 +397,30 @@ void test_5() throws Exception { final Long reviewId = review.getId(); - mockMvc - .perform( - patch("/api/v1/reviews/{reviewId}/display", reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value("false")) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_DISPLAY_STATUS_NOT_EMPTY')].status") - .value(notStatusEmpty.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'REVIEW_DISPLAY_STATUS_NOT_EMPTY')].message") - .value(notStatusEmpty.message())) - .andReturn(); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/reviews/{reviewId}/display", reviewId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result.assertThat().bodyJson().extractingPath("$.success").isEqualTo(false); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(notStatusEmpty.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(notStatusEmpty.message()); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java index c8e435e91..485d72659 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/help/integration/HelpIntegrationTest.java @@ -8,18 +8,11 @@ import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_AUTHORIZED; import static app.bottlenote.support.help.exception.HelpExceptionCode.HELP_NOT_FOUND; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.support.help.constant.HelpType; import app.bottlenote.support.help.domain.Help; import app.bottlenote.support.help.domain.HelpRepository; @@ -32,7 +25,6 @@ import app.bottlenote.support.help.fixture.HelpTestFactory; import app.bottlenote.user.domain.User; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -40,9 +32,9 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] HelpController") @@ -63,7 +55,7 @@ void setUp() { @DisplayName("Not null 필드에 null이 할당되면 예외를 반환한다.") @Test void test_1() throws Exception { - + // given Error error = Error.of(REQUIRED_HELP_TYPE); helpUpsertRequest = @@ -74,21 +66,26 @@ void test_1() throws Exception { List.of( new HelpImageItem( 1L, "https://bottlenote.s3.ap-northeast-2.amazonaws.com/images/1"))); - // given when - mockMvc - .perform( - post("/api/v1/help") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'REQUIRED_HELP_TYPE')].message").value(error.message())); + + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/help") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result.assertThat().bodyJson().extractingPath("$.errors[0].message").isEqualTo(error.message()); } @Nested @@ -99,26 +96,18 @@ class HelpRegisterControllerIntegrationTest { @Test void test_1() throws Exception { // given when - MvcResult result = - mockMvc - .perform( - post("/api/v1/help") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - HelpResultResponse helpResultResponse = - mapper.convertValue(response.getData(), HelpResultResponse.class); + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/help") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); // then + HelpResultResponse helpResultResponse = extractData(result, HelpResultResponse.class); assertEquals(REGISTER_SUCCESS, helpResultResponse.codeMessage()); } } @@ -139,49 +128,36 @@ void setUpTestData() { @DisplayName("문의글 목록을 조회할 수 있다.") @Test void test_1() throws Exception { - // given - MvcResult result = - mockMvc - .perform( - get("/api/v1/help") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - HelpListResponse helpListResponse = - mapper.convertValue(response.getData(), HelpListResponse.class); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/help") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); + // then + HelpListResponse helpListResponse = extractData(result, HelpListResponse.class); assertEquals(1, helpListResponse.totalCount()); } @DisplayName("문의글 상세 조회할 수 있다.") @Test void test_2() throws Exception { - // given - MvcResult result = - mockMvc - .perform( - get("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - HelpDetailItem helpDetailItem = mapper.convertValue(response.getData(), HelpDetailItem.class); + // when + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); + // then + HelpDetailItem helpDetailItem = extractData(result, HelpDetailItem.class); assertEquals(HelpType.USER, helpDetailItem.helpType()); } } @@ -202,27 +178,19 @@ void setUpTestData() { @DisplayName("문의글을 수정할 수 있다.") @Test void test_1() throws Exception { - // given when - MvcResult result = - mockMvc - .perform( - patch("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - HelpResultResponse helpResultResponse = - mapper.convertValue(response.getData(), HelpResultResponse.class); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); // then + HelpResultResponse helpResultResponse = extractData(result, HelpResultResponse.class); assertEquals(MODIFY_SUCCESS, helpResultResponse.codeMessage()); } @@ -233,22 +201,29 @@ void test_2() throws Exception { User anotherUser = userTestFactory.persistUser("test@naver.com", "테스터"); Error error = Error.of(HELP_NOT_AUTHORIZED); - // when then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken(anotherUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message") - .value(error.message())); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken(anotherUser).accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.UNAUTHORIZED); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } @DisplayName("존재하지 않는 문의글을 수정할 수 없다.") @@ -258,21 +233,29 @@ void test_3() throws Exception { long helpId = -1L; Error error = Error.of(HELP_NOT_FOUND); - // when then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", helpId) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message())); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/help/{helpId}", helpId) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } @DisplayName("Not null 필드에 null이 할당되면 예외를 반환한다.") @@ -285,22 +268,29 @@ void test_4() throws Exception { new HelpUpsertRequest( "로그인이 안됨", null, HelpType.USER, List.of(new HelpImageItem(1L, "https://test.com"))); - // when then - mockMvc - .perform( - patch("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsBytes(helpUpsertRequest)) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'CONTENT_NOT_EMPTY')].message") - .value(error.message())); + // when + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsBytes(helpUpsertRequest)) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } } @@ -320,26 +310,18 @@ void setUpTestData() { @DisplayName("문의글을 삭제할 수 있다.") @Test void test_1() throws Exception { - // given when - MvcResult result = - mockMvc - .perform( - delete("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - HelpResultResponse helpResultResponse = - mapper.convertValue(response.getData(), HelpResultResponse.class); + // when + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); // then + HelpResultResponse helpResultResponse = extractData(result, HelpResultResponse.class); assertEquals(DELETE_SUCCESS, helpResultResponse.codeMessage()); } @@ -350,20 +332,28 @@ void test_2() throws Exception { long helpId = -1L; Error error = Error.of(HELP_NOT_FOUND); - // when then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", helpId) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken(testUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_FOUND')].message").value(error.message())); + // when + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/help/{helpId}", helpId) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken(testUser).accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } @DisplayName("유저 본인이 작성한 글이 아니면 문의글을 삭제할 수 없다.") @@ -373,21 +363,28 @@ void test_3() throws Exception { User anotherUser = userTestFactory.persistUser("test2@naver.com", "테스터2"); Error error = Error.of(HELP_NOT_AUTHORIZED); - // when then - mockMvc - .perform( - delete("/api/v1/help/{helpId}", testHelp.getId()) - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + getToken(anotherUser).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isUnauthorized()) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'HELP_NOT_AUTHORIZED')].message") - .value(error.message())); + // when + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/help/{helpId}", testHelp.getId()) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken(anotherUser).accessToken()) + .with(csrf()) + .exchange(); + + // then + result.assertThat().hasStatus(HttpStatus.UNAUTHORIZED); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java index 122db5491..c896e9be5 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/support/report/integration/ReportIntegrationTest.java @@ -8,17 +8,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.fixture.AlcoholTestFactory; import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.review.domain.Review; import app.bottlenote.review.domain.ReviewRepository; import app.bottlenote.review.fixture.ReviewTestFactory; @@ -31,13 +27,12 @@ import app.bottlenote.support.report.dto.response.UserReportResponse; import app.bottlenote.user.domain.User; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] HelpController") @@ -59,29 +54,23 @@ void test_1() throws Exception { ReviewReportRequest reviewReportRequest = new ReviewReportRequest(review.getId(), ADVERTISEMENT, "이 리뷰는 광고 리뷰입니다."); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reports/review") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewReportRequest)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/reports/review") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewReportRequest)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then ReviewReport saved = reviewReportRepository.findAll().getFirst(); assertNotNull(saved); assertEquals(review.getId(), saved.getReviewId()); - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - ReviewReportResponse reviewReportResponse = - mapper.convertValue(response.getData(), ReviewReportResponse.class); - + ReviewReportResponse reviewReportResponse = extractData(result, ReviewReportResponse.class); assertTrue(reviewReportResponse.success()); } @@ -95,49 +84,45 @@ void test_2() throws Exception { ReviewReportRequest reviewReportRequest = new ReviewReportRequest(review.getId(), ADVERTISEMENT, "이 리뷰는 광고 리뷰입니다."); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reports/review") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewReportRequest)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + // when - 첫 번째 신고 (성공) + MvcTestResult firstResult = + mockMvcTester + .post() + .uri("/api/v1/reports/review") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewReportRequest)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); ReviewReport saved = reviewReportRepository.findById(reviewReportRequest.reportReviewId()).orElse(null); assertNotNull(saved); - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); ReviewReportResponse reviewReportResponse = - mapper.convertValue(response.getData(), ReviewReportResponse.class); - + extractData(firstResult, ReviewReportResponse.class); assertTrue(reviewReportResponse.success()); + // when - 두 번째 신고 (에러) Error error = Error.of(ALREADY_REPORTED_REVIEW); - mockMvc - .perform( - post("/api/v1/reports/review") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewReportRequest)) - .header("Authorization", "Bearer " + getToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(400)) - .andExpect( - jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].status") - .value(error.status().name())) - .andExpect( - jsonPath("$.errors[?(@.code == 'ALREADY_REPORTED_REVIEW')].message") - .value(error.message())); + MvcTestResult secondResult = + mockMvcTester + .post() + .uri("/api/v1/reports/review") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewReportRequest)) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + secondResult + .assertThat() + .hasStatus(HttpStatus.BAD_REQUEST) + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); } @DisplayName("서로 다른 IP로 5개의 신고가 누적되면 리뷰가 비활성화 된다.") @@ -150,7 +135,6 @@ void test_3() throws Exception { ReviewReportRequest reviewReportRequest = new ReviewReportRequest(review.getId(), ADVERTISEMENT, "이 리뷰는 광고 리뷰입니다."); - // 1~4번째 신고자 생성 및 신고 저장 for (int i = 1; i <= 4; i++) { User reporter = userTestFactory.persistUser("reporter-" + i, "신고자" + i); ReviewReport reviewReport = @@ -167,31 +151,25 @@ void test_3() throws Exception { Review beforeReview = reviewRepository.findById(reviewReportRequest.reportReviewId()).orElse(null); - // 5번째 신고를 위한 신고자 생성 User fifthReporter = userTestFactory.persistUser("report-5th", "다섯번째신고자"); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reports/review") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(reviewReportRequest)) - .header("Authorization", "Bearer " + getToken(fifthReporter).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/reports/review") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(reviewReportRequest)) + .header("Authorization", "Bearer " + getToken(fifthReporter).accessToken()) + .with(csrf()) + .exchange(); + + // then ReviewReport savedReport = reviewReportRepository.findById(reviewReportRequest.reportReviewId()).orElse(null); assertNotNull(savedReport); - String content = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse res = mapper.readValue(content, GlobalResponse.class); - ReviewReportResponse reviewReportResponse = - mapper.convertValue(res.getData(), ReviewReportResponse.class); + ReviewReportResponse reviewReportResponse = extractData(result, ReviewReportResponse.class); Review afterReview = reviewRepository.findById(reviewReportRequest.reportReviewId()).orElse(null); @@ -216,25 +194,19 @@ void test_4() throws Exception { UserReportRequest userReportRequest = new UserReportRequest(targetUser.getId(), UserReportType.FRAUD, "아주 나쁜놈이에요 신고합니다."); - MvcResult result = - mockMvc - .perform( - post("/api/v1/reports/user") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(userReportRequest)) - .header("Authorization", "Bearer " + getToken(reporter).accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); - - String contentAsString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(contentAsString, GlobalResponse.class); - UserReportResponse userReportResponse = - mapper.convertValue(response.getData(), UserReportResponse.class); - + // when + MvcTestResult result = + mockMvcTester + .post() + .uri("/api/v1/reports/user") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(userReportRequest)) + .header("Authorization", "Bearer " + getToken(reporter).accessToken()) + .with(csrf()) + .exchange(); + + // then + UserReportResponse userReportResponse = extractData(result, UserReportResponse.class); assertEquals(userReportResponse.getMessage(), SUCCESS.getMessage()); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java index 11f7172b3..4c478dd58 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserCommandIntegrationTest.java @@ -3,16 +3,10 @@ import static app.bottlenote.user.constant.UserStatus.DELETED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.user.constant.SocialType; import app.bottlenote.user.constant.UserStatus; import app.bottlenote.user.domain.User; @@ -27,14 +21,13 @@ import app.bottlenote.user.exception.UserExceptionCode; import app.bottlenote.user.fixture.UserTestFactory; import java.lang.reflect.Field; -import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] UserBasicController") @@ -52,24 +45,18 @@ void test_1() throws Exception { TokenItem token = getToken(user); // When - MvcResult result = - mockMvc - .perform( - delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/users") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); WithdrawUserResultResponse withdrawUserResultResponse = - mapper.convertValue(response.getData(), WithdrawUserResultResponse.class); + extractData(result, WithdrawUserResultResponse.class); userRepository .findById(withdrawUserResultResponse.userId()) @@ -83,23 +70,23 @@ void test_2() throws Exception { User user = userTestFactory.persistUser(); TokenItem token = getToken(user); - // 사용자를 탈퇴 상태로 변경 Field statusField = User.class.getDeclaredField("status"); statusField.setAccessible(true); statusField.set(user, UserStatus.DELETED); userRepository.save(user); - // When & Then - mockMvc - .perform( - delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); + // When + MvcTestResult result = + mockMvcTester + .delete() + .uri("/api/v1/users") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatusOk(); } @DisplayName("탈퇴한 회원이 재로그인 하는 경우 예외가 발생한다.") @@ -110,33 +97,41 @@ void test_3() throws Exception { TokenItem token = getToken(user); // 먼저 회원 탈퇴 - mockMvc - .perform( - delete("/api/v1/users") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()); + MvcTestResult deleteResult = + mockMvcTester + .delete() + .uri("/api/v1/users") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + + deleteResult.assertThat().hasStatusOk(); // When - 재로그인 시도 - mockMvc - .perform( - post("/api/v1/oauth/login") - .contentType(MediaType.APPLICATION_JSON) - .content( - mapper.writeValueAsString( - new OauthRequest(user.getEmail(), null, SocialType.KAKAO, null, null))) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(400)) - .andExpect(jsonPath("$.errors").isArray()) - .andExpect(jsonPath("$.errors[0].code").value(UserExceptionCode.USER_DELETED.name())) - .andExpect( - jsonPath("$.errors[0].message").value(UserExceptionCode.USER_DELETED.getMessage())); + MvcTestResult loginResult = + mockMvcTester + .post() + .uri("/api/v1/oauth/login") + .contentType(APPLICATION_JSON) + .content( + mapper.writeValueAsString( + new OauthRequest(user.getEmail(), null, SocialType.KAKAO, null, null))) + .with(csrf()) + .exchange(); + + // Then + loginResult.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + loginResult + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].code") + .isEqualTo(UserExceptionCode.USER_DELETED.name()); + loginResult + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(UserExceptionCode.USER_DELETED.getMessage()); } @DisplayName("닉네임 변경에 성공한다.") @@ -147,25 +142,19 @@ void test_4() throws Exception { TokenItem token = getToken(user); // When - MvcResult result = - mockMvc - .perform( - patch("/api/v1/users/nickname") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .content(mapper.writeValueAsString(new NicknameChangeRequest("newNickname"))) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/users/nickname") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .content(mapper.writeValueAsString(new NicknameChangeRequest("newNickname"))) + .with(csrf()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); NicknameChangeResponse nicknameChangeResponse = - mapper.convertValue(response.getData(), NicknameChangeResponse.class); + extractData(result, NicknameChangeResponse.class); assertEquals("newNickname", nicknameChangeResponse.getChangedNickname()); } @@ -178,23 +167,29 @@ void test_5() throws Exception { User otherUser = userTestFactory.persistUserWithNickname("중복닉네임"); TokenItem token = getToken(user); - // When & Then - mockMvc - .perform( - patch("/api/v1/users/nickname") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .content(mapper.writeValueAsString(new NicknameChangeRequest("중복닉네임"))) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(400)) - .andExpect(jsonPath("$.errors").isArray()) - .andExpect( - jsonPath("$.errors[0].code").value(UserExceptionCode.USER_NICKNAME_NOT_VALID.name())) - .andExpect( - jsonPath("$.errors[0].message") - .value(UserExceptionCode.USER_NICKNAME_NOT_VALID.getMessage())); + // When + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/users/nickname") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .content(mapper.writeValueAsString(new NicknameChangeRequest("중복닉네임"))) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].code") + .isEqualTo(UserExceptionCode.USER_NICKNAME_NOT_VALID.name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(UserExceptionCode.USER_NICKNAME_NOT_VALID.getMessage()); } @DisplayName("프로필 이미지 변경에 성공한다.") @@ -205,27 +200,19 @@ void test_6() throws Exception { TokenItem token = getToken(user); // When - MvcResult result = - mockMvc - .perform( - patch("/api/v1/users/profile-image") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .content( - mapper.writeValueAsString( - new ProfileImageChangeRequest("newProfileImageUrl"))) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/users/profile-image") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .content(mapper.writeValueAsString(new ProfileImageChangeRequest("newProfileImageUrl"))) + .with(csrf()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); ProfileImageChangeResponse profileImageChangeResponse = - mapper.convertValue(response.getData(), ProfileImageChangeResponse.class); + extractData(result, ProfileImageChangeResponse.class); assertEquals("newProfileImageUrl", profileImageChangeResponse.profileImageUrl()); } @@ -238,25 +225,19 @@ void test_7() throws Exception { TokenItem token = getToken(user); // When - MvcResult result = - mockMvc - .perform( - patch("/api/v1/users/profile-image") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .content(mapper.writeValueAsString(new ProfileImageChangeRequest(null))) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .patch() + .uri("/api/v1/users/profile-image") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .content(mapper.writeValueAsString(new ProfileImageChangeRequest(null))) + .with(csrf()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse response = mapper.readValue(responseString, GlobalResponse.class); ProfileImageChangeResponse profileImageChangeResponse = - mapper.convertValue(response.getData(), ProfileImageChangeResponse.class); + extractData(result, ProfileImageChangeResponse.class); assertNull(profileImageChangeResponse.profileImageUrl()); } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java index 43ba78aae..9c32081a7 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/integration/UserQueryIntegrationTest.java @@ -3,11 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import app.bottlenote.IntegrationTestSupport; import app.bottlenote.alcohols.domain.Alcohol; @@ -15,7 +12,6 @@ import app.bottlenote.common.fixture.MyPageTestData; import app.bottlenote.common.fixture.TestDataSetupHelper; import app.bottlenote.global.data.response.Error; -import app.bottlenote.global.data.response.GlobalResponse; import app.bottlenote.rating.fixture.RatingTestFactory; import app.bottlenote.review.fixture.ReviewTestFactory; import app.bottlenote.user.domain.User; @@ -24,7 +20,6 @@ import app.bottlenote.user.dto.response.TokenItem; import app.bottlenote.user.exception.UserExceptionCode; import app.bottlenote.user.fixture.UserTestFactory; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -32,8 +27,8 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.assertj.MvcTestResult; @Tag("integration") @DisplayName("[integration] [controller] UserQueryController") @@ -60,30 +55,23 @@ void test_1() throws Exception { } TokenItem token = getToken(me); - // 팔로잉 관계 생성 for (User target : otherUsers) { userTestFactory.persistFollow(me, target); } // When - MvcResult result = - mockMvc - .perform( - get("/api/v1/follow/{userId}/following-list", me.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .header("Authorization", "Bearer " + token.accessToken())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/follow/{userId}/following-list", me.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header("Authorization", "Bearer " + token.accessToken()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); FollowingSearchResponse followingSearchResponse = - mapper.convertValue(globalResponse.getData(), FollowingSearchResponse.class); + extractData(result, FollowingSearchResponse.class); assertNotNull(followingSearchResponse); assertEquals(followingSearchResponse.totalCount(), otherUsers.size()); @@ -100,30 +88,23 @@ void test_2() throws Exception { } TokenItem token = getToken(me); - // 팔로워 관계 생성 (다른 유저들이 나를 팔로우) for (User follower : followers) { userTestFactory.persistFollow(follower, me); } // When - MvcResult result = - mockMvc - .perform( - get("/api/v1/follow/{userId}/follower-list", me.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .header("Authorization", "Bearer " + token.accessToken())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/follow/{userId}/follower-list", me.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header("Authorization", "Bearer " + token.accessToken()) + .exchange(); // Then - String responseString = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - GlobalResponse globalResponse = mapper.readValue(responseString, GlobalResponse.class); FollowerSearchResponse followerSearchResponse = - mapper.convertValue(globalResponse.getData(), FollowerSearchResponse.class); + extractData(result, FollowerSearchResponse.class); assertNotNull(followerSearchResponse); assertEquals(followerSearchResponse.totalCount(), followers.size()); @@ -143,19 +124,23 @@ void test_1() throws Exception { User targetUser = data.getUser(1); TokenItem token = getToken(me); - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .header("Authorization", "Bearer " + token.accessToken())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(targetUser.getId())) - .andReturn(); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header("Authorization", "Bearer " + token.accessToken()) + .exchange(); + + // Then + result.assertThat().hasStatusOk(); + result + .assertThat() + .bodyJson() + .extractingPath("$.data.userId") + .isEqualTo(targetUser.getId().intValue()); assertNotEquals(targetUser.getId(), me.getId()); } @@ -168,20 +153,24 @@ void test_2() throws Exception { User me = data.getUser(0); TokenItem token = getToken(me); - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}", me.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf()) - .header("Authorization", "Bearer " + token.accessToken())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(me.getId())) - .andExpect(jsonPath("$.data.isMyPage").value(true)) - .andReturn(); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}", me.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .header("Authorization", "Bearer " + token.accessToken()) + .exchange(); + + // Then + result.assertThat().hasStatusOk(); + result + .assertThat() + .bodyJson() + .extractingPath("$.data.userId") + .isEqualTo(me.getId().intValue()); + result.assertThat().bodyJson().extractingPath("$.data.isMyPage").isEqualTo(true); } @DisplayName("비회원 유저가 타인의 마이페이지를 조회할 수 있다.") @@ -191,18 +180,22 @@ void test_3() throws Exception { MyPageTestData data = testDataSetupHelper.setupMyPageTestData(); User targetUser = data.getUser(1); - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}", targetUser.getId()) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andExpect(jsonPath("$.data.userId").value(targetUser.getId())) - .andReturn(); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}", targetUser.getId()) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatusOk(); + result + .assertThat() + .bodyJson() + .extractingPath("$.data.userId") + .isEqualTo(targetUser.getId().intValue()); } @DisplayName("유저가 존재하지 않는 경우 MYPAGE_NOT_ACCESSIBLE 에러를 발생한다.") @@ -212,17 +205,32 @@ void test_4() throws Exception { Error error = Error.of(UserExceptionCode.MYPAGE_NOT_ACCESSIBLE); final Long nonExistentUserId = 999999L; - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}", nonExistentUserId) - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}", nonExistentUserId) + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatus(HttpStatus.FORBIDDEN); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].code") + .isEqualTo(String.valueOf(error.code())); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } } @@ -240,25 +248,24 @@ void test_1() throws Exception { reviewTestFactory.persistReview(targetUser, alcohol); TokenItem token = getToken(me); - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) - .param("keyword", "") - .param("regionId", "") - .param("sortType", "LATEST") - .param("sortOrder", "DESC") - .param("cursor", "0") - .param("pageSize", "50") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token.accessToken()) - .with(csrf())) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.code").value(200)) - .andExpect(jsonPath("$.data").exists()) - .andReturn(); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) + .param("keyword", "") + .param("regionId", "") + .param("sortType", "LATEST") + .param("sortOrder", "DESC") + .param("cursor", "0") + .param("pageSize", "50") + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + token.accessToken()) + .with(csrf()) + .exchange(); + // Then + result.assertThat().hasStatusOk(); assertNotEquals(targetUser.getId(), me.getId()); } @@ -270,20 +277,23 @@ void test_3() throws Exception { Alcohol alcohol = alcoholTestFactory.persistAlcohol(); reviewTestFactory.persistReview(targetUser, alcohol); - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) - .param("keyword", "") - .param("regionId", "") - .param("sortType", "LATEST") - .param("sortOrder", "DESC") - .param("cursor", "0") - .param("pageSize", "50") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}/my-bottle/reviews", targetUser.getId()) + .param("keyword", "") + .param("regionId", "") + .param("sortType", "LATEST") + .param("sortOrder", "DESC") + .param("cursor", "0") + .param("pageSize", "50") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); } @DisplayName("마이보틀 유저가 존재하지 않는 경우 REQUIRED_USER_ID 예외를 반환한다.") @@ -293,23 +303,38 @@ void test_4() throws Exception { Error error = Error.of(UserExceptionCode.REQUIRED_USER_ID); final Long nonExistentUserId = 999999L; - // When & Then - mockMvc - .perform( - get("/api/v1/my-page/{userId}/my-bottle/reviews", nonExistentUserId) - .param("keyword", "") - .param("regionId", "") - .param("sortType", "LATEST") - .param("sortOrder", "DESC") - .param("cursor", "0") - .param("pageSize", "50") - .contentType(MediaType.APPLICATION_JSON) - .with(csrf())) - .andDo(print()) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errors[0].code").value(String.valueOf(error.code()))) - .andExpect(jsonPath("$.errors[0].status").value(error.status().name())) - .andExpect(jsonPath("$.errors[0].message").value(error.message())); + // When + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/my-page/{userId}/my-bottle/reviews", nonExistentUserId) + .param("keyword", "") + .param("regionId", "") + .param("sortType", "LATEST") + .param("sortOrder", "DESC") + .param("cursor", "0") + .param("pageSize", "50") + .contentType(APPLICATION_JSON) + .with(csrf()) + .exchange(); + + // Then + result.assertThat().hasStatus(HttpStatus.BAD_REQUEST); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].code") + .isEqualTo(String.valueOf(error.code())); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].status") + .isEqualTo(error.status().name()); + result + .assertThat() + .bodyJson() + .extractingPath("$.errors[0].message") + .isEqualTo(error.message()); } } } From a5218fadb548a9ce7f4c7d05d573e62a8dc052b0 Mon Sep 17 00:00:00 2001 From: rlagu Date: Tue, 31 Mar 2026 00:42:31 +0900 Subject: [PATCH 04/31] =?UTF-8?q?docs:=20MockMvcTester=20=EB=A7=88?= =?UTF-8?q?=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=20=EB=AC=B8=EC=84=9C=20=EC=99=84=EB=A3=8C=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../mockmvc-to-mockmvctester-migration.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) rename plan/{ => complete}/mockmvc-to-mockmvctester-migration.md (92%) diff --git a/plan/mockmvc-to-mockmvctester-migration.md b/plan/complete/mockmvc-to-mockmvctester-migration.md similarity index 92% rename from plan/mockmvc-to-mockmvctester-migration.md rename to plan/complete/mockmvc-to-mockmvctester-migration.md index b7d457f2c..bc0c0371f 100644 --- a/plan/mockmvc-to-mockmvctester-migration.md +++ b/plan/complete/mockmvc-to-mockmvctester-migration.md @@ -1,3 +1,25 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-03-31 + +** Core Achievements ** +- 통합 테스트 13개 파일 MockMvc에서 MockMvcTester로 마이그레이션 완료 (76개 테스트 메서드) +- 에러 케이스 14개: extractData() 대신 assertThat().hasStatus() + bodyJson().extractingPath() 패턴 적용 +- jsonPath 필터식 $.errors[?(@.code == '...')] → $.errors[0] 인덱스 접근으로 단순화 + +** Key Components ** +- IntegrationTestSupport: mockMvcTester 필드 + extractData(MvcTestResult) 헬퍼 활용 +- 마이그레이션 파일: Picks, Likes, BusinessSupport, Rating, Banner, UserHistory, ReviewReply, Report, UserQuery, UserCommand, Help, Review (13개) + +** Deferred Items ** +- Phase 3 정리: IntegrationTestSupport에서 레거시 MockMvc 필드/메서드 제거 (JpaAuditingIntegrationTest 마이그레이션 후) +- DailyDataReportIntegrationTest: MockMvc 미사용 서비스 레벨 테스트 (scope 외) +================================================================================ +``` + # MockMvc to MockMvcTester 마이그레이션 계획 ## 1. 개요 From 79cd6a72d98299cfa7e14fe551cc04ae053bde98 Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 31 Mar 2026 15:00:29 +0900 Subject: [PATCH 05/31] chore: set default value for `business_support_type` to 'GENERAL' and add index for `resource_key` in MySQL schema --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index a3fa81fe4..c516dde71 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit a3fa81fe4c0fa2fcf3017194637e54083c66caba +Subproject commit c516dde71f657dfb670a69e5da35192567933991 From 4a777878b1a6cc26df400aa58d1a21c39b58d21b Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 31 Mar 2026 15:00:50 +0900 Subject: [PATCH 06/31] chore: replace initialization scripts with Liquibase schema management in test configuration --- bottlenote-mono/build.gradle | 1 + .../operation/utils/TestContainersConfig.java | 5 +---- bottlenote-product-api/build.gradle | 6 ++++-- .../src/test/resources/application-test.yml | 12 +++++------- gradle/libs.versions.toml | 8 +++++++- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/bottlenote-mono/build.gradle b/bottlenote-mono/build.gradle index 14e4a02e7..7faaa2c97 100644 --- a/bottlenote-mono/build.gradle +++ b/bottlenote-mono/build.gradle @@ -46,6 +46,7 @@ dependencies { testImplementation libs.mockito.inline testImplementation libs.bundles.testcontainers.complete testImplementation libs.archunit + testImplementation libs.liquibase.core } bootJar { diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 3f2a294dc..c10a47d34 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -35,10 +35,7 @@ MySQLContainer mysqlContainer() { .withReuse(true) .withDatabaseName("bottlenote") .withUsername("root") - .withPassword("root") - .withInitScripts( - "storage/mysql/init/00-init-config-table.sql", - "storage/mysql/init/01-init-core-table.sql"); + .withPassword("root"); } /** Redis 컨테이너를 Spring Bean으로 등록합니다. @ServiceConnection이 자동으로 Redis 설정을 처리합니다. */ diff --git a/bottlenote-product-api/build.gradle b/bottlenote-product-api/build.gradle index 6bab1e515..ea655f9e0 100644 --- a/bottlenote-product-api/build.gradle +++ b/bottlenote-product-api/build.gradle @@ -50,6 +50,9 @@ dependencies { // Test - TestContainers testImplementation libs.bundles.testcontainers.complete + // Test - Liquibase (schema initialization) + testImplementation libs.liquibase.core + // Test - ArchUnit testImplementation libs.archunit @@ -83,8 +86,7 @@ sourceSets { } test { resources { - srcDirs = ['src/test/resources', - "${rootProject.projectDir}/git.environment-variables"] + srcDirs = ['src/test/resources'] } } } diff --git a/bottlenote-product-api/src/test/resources/application-test.yml b/bottlenote-product-api/src/test/resources/application-test.yml index 851f81b44..b4431f3d2 100644 --- a/bottlenote-product-api/src/test/resources/application-test.yml +++ b/bottlenote-product-api/src/test/resources/application-test.yml @@ -1,3 +1,4 @@ + spring: main: allow-bean-definition-overriding: true @@ -28,13 +29,10 @@ spring: url: jdbc:tc:mysql:8.0.32:///bottlenote username: root password: root - sql: - init: - # mode: always - # schema-locations: - # - classpath:storage/mysql/init/00-init-config-table.sql - # - classpath:storage/mysql/init/01-init-core-table.sql - # # - classpath:storage/mysql/init/*.sql + # Liquibase + liquibase: + change-log: classpath:storage/mysql/changelog/schema.mysql.sql + enabled: true # JPA jpa: diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f79363e72..0182e7a1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,7 +22,7 @@ lombok = "1.18.30" google-guava = "32.1.2-jre" # Utilities -commons-lang3 = "3.12.0" +commons-lang3 = "3.17.0" jetbrains-annotations = "26.0.2" # Caching @@ -51,6 +51,9 @@ archunit = "1.4.0" spring-restdocs = "3.0.3" restdocs-api-spec = "0.19.4" +# Schema Management +liquibase-core = "4.33.0" + # Scheduling quartz = "2.3.2" @@ -145,6 +148,9 @@ loki-logback-appender = { module = "com.github.loki4j:loki-logback-appender", ve # Network netty-bom = { module = "io.netty:netty-bom", version.ref = "netty" } +# Schema Management +liquibase-core = { module = "org.liquibase:liquibase-core", version.ref = "liquibase-core" } + # Testing testng = { module = "org.testng:testng", version.ref = "testng" } testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } From 18257856509cf2072d485c31f201cd7bd0bb99ed Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 31 Mar 2026 16:46:26 +0900 Subject: [PATCH 07/31] chore: integrate Liquibase for test schema management and update build configuration --- bottlenote-admin-api/build.gradle.kts | 9 ++++++++- .../src/test/resources/application-test.yml | 7 ++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/bottlenote-admin-api/build.gradle.kts b/bottlenote-admin-api/build.gradle.kts index ff85cee23..b04d1bcb6 100644 --- a/bottlenote-admin-api/build.gradle.kts +++ b/bottlenote-admin-api/build.gradle.kts @@ -31,6 +31,9 @@ dependencies { testImplementation(libs.spring.restdocs.mockmvc) testImplementation(libs.restdocs.api.spec.mockmvc) + // Test - Liquibase (schema initialization) + testImplementation(libs.liquibase.core) + // Test - Testcontainers testImplementation(libs.bundles.testcontainers.complete) @@ -46,7 +49,7 @@ sourceSets { } test { resources { - srcDirs("src/test/resources", "${rootProject.projectDir}/git.environment-variables") + srcDirs("src/test/resources") } } } @@ -55,6 +58,10 @@ tasks.processResources { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } +tasks.jar { + exclude("storage/mysql/changelog/**") +} + tasks.processTestResources { duplicatesStrategy = DuplicatesStrategy.EXCLUDE } diff --git a/bottlenote-admin-api/src/test/resources/application-test.yml b/bottlenote-admin-api/src/test/resources/application-test.yml index b977be766..7aa463269 100644 --- a/bottlenote-admin-api/src/test/resources/application-test.yml +++ b/bottlenote-admin-api/src/test/resources/application-test.yml @@ -23,9 +23,10 @@ spring: url: jdbc:tc:mysql:8.0.32:///bottlenote username: root password: root - sql: - init: - mode: never + # Liquibase + liquibase: + change-log: classpath:storage/mysql/changelog/schema.mysql.sql + enabled: true # JPA jpa: From 2db51f182e8294d8f2a917beee2f4b67cfbac1ee Mon Sep 17 00:00:00 2001 From: hgkim Date: Tue, 31 Mar 2026 16:53:53 +0900 Subject: [PATCH 08/31] chore: update deploy_v2_development workflow to conditionally run prepare-build job based on event type and conclusion --- .github/workflows/deploy_v2_development.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/deploy_v2_development.yml b/.github/workflows/deploy_v2_development.yml index 105522e00..d7b4da8be 100644 --- a/.github/workflows/deploy_v2_development.yml +++ b/.github/workflows/deploy_v2_development.yml @@ -15,6 +15,9 @@ concurrency: jobs: prepare-build: + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-24.04-arm outputs: short-sha: ${{ steps.versions.outputs.short-sha }} From cf31964b79c47b643fa75151eac54d861ab78832 Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 22:31:47 +0900 Subject: [PATCH 09/31] refactor: MySQL scripts and update documentation Moved MySQL initialization scripts to backup directory for better organization. Enhanced documentation with SQL script guidelines and ensured consistent formatting. Added parent_id column and index to regions table for improved structure. --- build.gradle | 3 ++- git.environment-variables | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index de9d1fa9f..2a8f10b85 100644 --- a/build.gradle +++ b/build.gradle @@ -23,8 +23,9 @@ allprojects { } } -// 서브모듈 공통 설정 +// 서브모듈 공통 설정/ subprojects { + apply plugin: 'java-library' apply plugin: 'org.springframework.boot' apply plugin: 'io.spring.dependency-management' diff --git a/git.environment-variables b/git.environment-variables index c516dde71..57cd5c5bb 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit c516dde71f657dfb670a69e5da35192567933991 +Subproject commit 57cd5c5bbeb473be1c2b1036e6ea718a2f10227b From 157c08a5a7b0d07693e1b658bed6e77f1fb46a00 Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 22:49:47 +0900 Subject: [PATCH 10/31] refactor: implement hierarchy-based region filtering across repositories Added `parentId` to region entities and responses. Updated repositories and query methods to support filtering by child regions when a parent region is selected. --- .../app/bottlenote/alcohols/domain/Region.java | 8 ++++++++ .../alcohols/domain/RegionRepository.java | 2 ++ .../alcohols/dto/response/AdminRegionItem.java | 3 ++- .../alcohols/dto/response/RegionsItem.java | 1 + .../alcohols/repository/AlcoholQuerySupporter.java | 14 +++++++++++--- .../repository/JpaRegionQueryRepository.java | 8 ++++++-- .../rating/repository/RatingQuerySupporter.java | 14 +++++++++++--- .../user/repository/UserQuerySupporter.java | 14 +++++++++++--- 8 files changed, 52 insertions(+), 12 deletions(-) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java index 59240f388..537e3258e 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/Region.java @@ -3,9 +3,12 @@ import app.bottlenote.common.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -41,4 +44,9 @@ public class Region extends BaseEntity { @Comment("지역 설명") @Column(name = "description", nullable = true) private String description; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + @Comment("상위 지역") + private Region parent; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java index c147bf11f..0d392c687 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/domain/RegionRepository.java @@ -14,4 +14,6 @@ public interface RegionRepository { List findAllRegionsResponse(); Page findAllRegions(String keyword, Pageable pageable); + + List findChildRegionIds(Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java index 34655c496..f310ac599 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/AdminRegionItem.java @@ -9,4 +9,5 @@ public record AdminRegionItem( String continent, String description, LocalDateTime createdAt, - LocalDateTime modifiedAt) {} + LocalDateTime modifiedAt, + Long parentId) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java index e59762241..613c778f3 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/dto/response/RegionsItem.java @@ -10,4 +10,5 @@ public class RegionsItem { private final String korName; private final String engName; private final String description; + private final Long parentId; } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java index a0b82d139..9ec65d4f6 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/AlcoholQuerySupporter.java @@ -31,13 +31,17 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Objects; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @Slf4j @Component +@RequiredArgsConstructor public class AlcoholQuerySupporter { + private final app.bottlenote.alcohols.domain.RegionRepository regionRepository; + /** 주류에 연결된 테이스팅 태그 목록을 문자열로 조회 */ public static Expression getTastingTags() { return ExpressionUtils.as( @@ -197,11 +201,15 @@ public BooleanExpression eqCategory(AlcoholCategoryGroup category) { return alcohol.categoryGroup.stringValue().like("%" + category + "%"); } - /** 지역 일치 여부 조건 생성 */ + /** 지역 일치 여부 조건 생성 (부모 지역이면 하위 지역 포함) */ public BooleanExpression eqRegion(Long regionId) { if (regionId == null) return null; - - return alcohol.region.id.eq(regionId); + List childIds = regionRepository.findChildRegionIds(regionId); + if (childIds.isEmpty()) return alcohol.region.id.eq(regionId); + List regionIds = new java.util.ArrayList<>(childIds.size() + 1); + regionIds.add(regionId); + regionIds.addAll(childIds); + return alcohol.region.id.in(regionIds); } /** 사용자가 주류에 준 평점 조회 (QueryDSL 경로 버전) */ diff --git a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java index ec49e9b62..4cda3d481 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/alcohols/repository/JpaRegionQueryRepository.java @@ -18,7 +18,7 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito @Override @Query( """ - select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description) + select new app.bottlenote.alcohols.dto.response.RegionsItem(r.id, r.korName, r.engName, r.description, r.parent.id) from region r order by r.id asc """) List findAllRegionsResponse(); @@ -27,7 +27,7 @@ public interface JpaRegionQueryRepository extends RegionRepository, CrudReposito @Query( """ select new app.bottlenote.alcohols.dto.response.AdminRegionItem( - r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt + r.id, r.korName, r.engName, r.continent, r.description, r.createAt, r.lastModifyAt, r.parent.id ) from region r where (:keyword is null or :keyword = '' @@ -35,4 +35,8 @@ or r.korName like concat('%', :keyword, '%') or r.engName like concat('%', :keyword, '%')) """) Page findAllRegions(@Param("keyword") String keyword, Pageable pageable); + + @Override + @Query("select r.id from region r where r.parent.id = :parentId") + List findChildRegionIds(@Param("parentId") Long parentId); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java index 1409afa7d..8862ad1ec 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java @@ -20,11 +20,15 @@ import com.querydsl.core.util.StringUtils; import java.util.List; import java.util.Objects; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class RatingQuerySupporter { + private final app.bottlenote.alcohols.domain.RegionRepository regionRepository; + /** * CursorPageable 생성 * @@ -98,11 +102,15 @@ protected BooleanExpression eqAlcoholCategory(AlcoholCategoryGroup category) { return alcohol.categoryGroup.stringValue().like("%" + category + "%"); } - /** 리전을 검색하는 조건 */ + /** 리전을 검색하는 조건 (부모 지역이면 하위 지역 포함) */ protected BooleanExpression eqAlcoholRegion(Long regionId) { if (regionId == null) return null; - - return alcohol.region.id.eq(regionId); + List childIds = regionRepository.findChildRegionIds(regionId); + if (childIds.isEmpty()) return alcohol.region.id.eq(regionId); + List regionIds = new java.util.ArrayList<>(childIds.size() + 1); + regionIds.add(regionId); + regionIds.addAll(childIds); + return alcohol.region.id.in(regionIds); } /** diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/UserQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/UserQuerySupporter.java index 2fd26ab19..ba33cb285 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/UserQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/UserQuerySupporter.java @@ -23,11 +23,15 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class UserQuerySupporter { + private final app.bottlenote.alcohols.domain.RegionRepository regionRepository; + /** * 마이 페이지 사용자의 팔로워 수 를 조회한다. * @@ -115,11 +119,15 @@ private boolean isHasNext(MyBottlePageableCriteria request, List myBottleList return myBottleList.size() > request.pageSize(); } - /** 지역(리전) 검색조건 */ + /** 지역(리전) 검색조건 (부모 지역이면 하위 지역 포함) */ public BooleanExpression eqRegion(Long regionId) { if (regionId == null) return null; - - return alcohol.region.id.eq(regionId); + List childIds = regionRepository.findChildRegionIds(regionId); + if (childIds.isEmpty()) return alcohol.region.id.eq(regionId); + List regionIds = new java.util.ArrayList<>(childIds.size() + 1); + regionIds.add(regionId); + regionIds.addAll(childIds); + return alcohol.region.id.in(regionIds); } /** 술 이름을 검색하는 조건 */ From 41ee8f383bd5786c5603fd075ed47ef5704c566d Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 22:50:08 +0900 Subject: [PATCH 11/31] docs: introduce parent-child hierarchy for regions Added `parentId` self-referencing column to regions table and updated related entities. Enhanced repositories, query methods, and APIs to support region hierarchy filtering. Updated DTOs, queries, and caching logic --- plan/region-hierarchy.md | 161 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 plan/region-hierarchy.md diff --git a/plan/region-hierarchy.md b/plan/region-hierarchy.md new file mode 100644 index 000000000..e722f41d8 --- /dev/null +++ b/plan/region-hierarchy.md @@ -0,0 +1,161 @@ +# Region 계층 구조 도입 계획 + +## Context + +현재 Region(지역)은 플랫 구조로, 스코틀랜드 하위 지역(캠벨타운, 아일라, 스페이사이드 등)이 독립 레코드로 존재한다. +"스코틀랜드"(id=19)를 "스코틀랜드/전체"로 변경하고, 이를 선택하면 모든 스코틀랜드 하위 지역의 위스키가 함께 조회되도록 한다. +향후 다른 국가(미국, 일본 등)에도 동일 패턴을 적용할 수 있어야 한다. + +## 접근 방식: Region 엔티티에 parentId Self-Reference 추가 + +이름 기반 LIKE 검색(id=20 "스코트랜드" 오타 문제로 불안정)이나 groupCode(parentId의 열화판) 대신, +FK 기반 부모-자식 관계로 데이터 무결성을 보장하는 방식을 선택한다. + +## 작업 범위 + +- 코드 변경 + Liquibase changelog 추가 +- 초기 데이터(04-data-region.sql) 수정하지 않음 +- id=20 오타 수정하지 않음 + +## 조회 동작 + +"스코틀랜드/전체"(id=19) 선택 시: id=19 자체 + 하위 지역(14,15,16,17,18,20) 위스키 모두 조회 + +--- + +## 수정 파일 목록 + +### Phase 1: DB 스키마 + 엔티티 + +> [완료] 2026-04-04: DB 스키마 변경 반영 (production + development) +> - changeset `rlagu:20260404-1`: parent_id 컬럼 추가 +> - changeset `rlagu:20260404-2`: idx_regions_parent_id 인덱스 추가 +> - 스코틀랜드 데이터 UPDATE: 수동 쿼리로 양쪽 DB에 반영 완료 + +| 파일 | 변경 | +|------|------| +| `git.environment-variables/storage/mysql/changelog/schema.mysql.sql` | parent_id 컬럼 + 인덱스 changeset 추가, 스코틀랜드 데이터 UPDATE | +| `bottlenote-mono/.../alcohols/domain/Region.java` | `parent` 필드 (ManyToOne self-reference) 추가 | + +**changelog 추가 내용:** +```sql +-- changeset rlagu:20260404-1 splitStatements:false +-- comment: regions 테이블에 parent_id 컬럼 추가 +ALTER TABLE regions ADD COLUMN parent_id BIGINT NULL COMMENT '상위 지역 ID'; + +-- changeset rlagu:20260404-2 splitStatements:false +-- comment: regions parent_id 인덱스 추가 +CREATE INDEX idx_regions_parent_id ON regions(parent_id); +``` + +**수동 반영 (양쪽 DB에 직접 실행):** +```sql +UPDATE regions SET kor_name = '스코틀랜드/전체', eng_name = 'Scotland' WHERE id = 19; +UPDATE regions SET parent_id = 19 WHERE id IN (14, 15, 16, 17, 18, 20); +``` + +**Region.java 변경:** +```java +@ManyToOne(fetch = FetchType.LAZY) +@JoinColumn(name = "parent_id") +@Comment("상위 지역") +private Region parent; +``` + +### Phase 2: Repository 계층 + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/domain/RegionRepository.java` | `findChildRegionIds(Long parentId)` 메서드 추가 | +| `bottlenote-mono/.../alcohols/repository/JpaRegionQueryRepository.java` | JPQL 구현 + findAllRegionsResponse에 parentId 추가 | + +### Phase 3: RegionIdResolver (신규) + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/repository/RegionIdResolver.java` | **신규** - regionId -> List 변환 (부모면 자신+자식 반환) | +| `bottlenote-mono/.../global/cache/local/LocalCacheType.java` | `REGION_CHILDREN_CACHE` 추가 | + +**핵심 로직:** +```java +@Component +public class RegionIdResolver { + // regionId가 부모 Region이면: [regionId, childId1, childId2, ...] 반환 + // regionId가 자식 Region이면: [regionId] 반환 + // null이면: null 반환 + @Cacheable(value = "region_children_cache") + public List resolveRegionIds(Long regionId) { ... } +} +``` + +### Phase 4: QuerySupporter 시그니처 변경 (3개) + +| 파일 (라인) | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/repository/AlcoholQuerySupporter.java` (201) | `eqRegion(Long)` -> `eqRegion(List)`, IN 절 사용 | +| `bottlenote-mono/.../rating/repository/RatingQuerySupporter.java` (102) | `eqAlcoholRegion(Long)` -> `eqAlcoholRegion(List)`, IN 절 사용 | +| `bottlenote-mono/.../user/repository/UserQuerySupporter.java` (119) | `eqRegion(Long)` -> `eqRegion(List)`, IN 절 사용 | + +**변경 패턴 (3곳 동일):** +```java +// Before +public BooleanExpression eqRegion(Long regionId) { + if (regionId == null) return null; + return alcohol.region.id.eq(regionId); +} + +// After +public BooleanExpression eqRegion(List regionIds) { + if (regionIds == null || regionIds.isEmpty()) return null; + if (regionIds.size() == 1) return alcohol.region.id.eq(regionIds.get(0)); + return alcohol.region.id.in(regionIds); +} +``` + +### Phase 5: Repository 구현체에서 resolve 호출 (3개) + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/repository/CustomAlcoholQueryRepositoryImpl.java` | RegionIdResolver 주입, 4곳에서 resolve 호출 (라인 99, 122, 329, 343) | +| `bottlenote-mono/.../rating/repository/CustomRatingQueryRepositoryImpl.java` | RegionIdResolver 주입, 2곳에서 resolve 호출 (라인 63, 85) | +| `bottlenote-mono/.../user/repository/CustomUserRepositoryImpl.java` | RegionIdResolver 주입, 6곳에서 resolve 호출 (라인 113, 185, 227, 253, 292, 323) | + +**호출 패턴 변경:** +```java +// Before +supporter.eqRegion(criteria.regionId()) + +// After +supporter.eqRegion(regionIdResolver.resolveRegionIds(criteria.regionId())) +``` + +### Phase 6: API 응답 DTO 수정 + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/dto/response/RegionsItem.java` | `parentId` 필드 추가 | +| `bottlenote-mono/.../alcohols/dto/response/AdminRegionItem.java` | `parentId` 필드 추가 | + +--- + +## 영향받는 API 목록 + +| API | 엔드포인트 | +|------|----------| +| 알코올 검색 | `GET /api/v1/alcohols/search?regionId=19` | +| 별점 평가 목록 | `GET /api/v1/rating?regionId=19` | +| MyBottle (Review) | `GET /api/v1/my-page/{userId}/my-bottle/reviews?regionId=19` | +| MyBottle (Rating) | `GET /api/v1/my-page/{userId}/my-bottle/ratings?regionId=19` | +| MyBottle (Picks) | `GET /api/v1/my-page/{userId}/my-bottle/picks?regionId=19` | +| Region 목록 | `GET /api/v1/regions` (parentId 필드 추가) | +| Admin Region 목록 | `GET /admin/api/v1/regions` (parentId 필드 추가) | +| Admin 알코올 검색 | Admin API 내 알코올 검색 | + +--- + +## 검증 방법 + +1. **컴파일 확인**: `./gradlew compileJava` +2. **단위 테스트**: `./gradlew unit_test` - RegionsItem/AdminRegionItem 생성자 변경 영향 확인 +3. **통합 테스트**: `./gradlew integration_test` - Region 필터링이 사용되는 API 테스트 통과 확인 +4. **수동 검증**: regionId=19로 검색 시 하위 지역 위스키도 함께 반환되는지 확인 From f41d4a0698e97d948032bcd2f9039745d41b7cb2 Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 22:59:54 +0900 Subject: [PATCH 12/31] test: add nullable field to AlcoholsHelper region test data --- .../src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index ce726b43a..40ff99a5a 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -140,7 +140,8 @@ object AlcoholsHelper { listOf("유럽", "유럽", "아시아")[i - 1], "지역 설명 $i", LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0) + LocalDateTime.of(2024, 6, i, 0, 0), + null ) } From c70955cadf2d6c35a3fb4bb93d4f3d96dfd0851d Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 23:17:40 +0900 Subject: [PATCH 13/31] test: update RegionServiceTest and RestReferenceControllerTest with parentId field addition Updated `RegionsItem` test data to include `parentId` field. Adjusted `ModuleConfig` to inject `RegionRepository` dependencies with `@Lazy` annotation for `AlcoholQuerySupporter` and `RatingQuerySupporter`. --- .../test/java/app/bottlenote/config/ModuleConfig.java | 10 ++++++---- .../alcohols/service/RegionServiceTest.java | 11 ++++++----- .../docs/alcohols/RestReferenceControllerTest.java | 11 ++++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java index 855f3f9e0..2e3ececb9 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/config/ModuleConfig.java @@ -1,5 +1,6 @@ package app.bottlenote.config; +import app.bottlenote.alcohols.domain.RegionRepository; import app.bottlenote.alcohols.repository.AlcoholQuerySupporter; import app.bottlenote.global.data.serializers.CustomDeserializers; import app.bottlenote.global.data.serializers.CustomDeserializers.TagListDeserializer; @@ -14,6 +15,7 @@ import java.time.LocalDateTime; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Lazy; @TestConfiguration public class ModuleConfig { @@ -37,8 +39,8 @@ public ObjectMapper objectMapper() { } @Bean - public AlcoholQuerySupporter alcoholQuerySupporter() { - return new AlcoholQuerySupporter(); + public AlcoholQuerySupporter alcoholQuerySupporter(@Lazy RegionRepository regionRepository) { + return new AlcoholQuerySupporter(regionRepository); } @Bean @@ -52,7 +54,7 @@ public ReviewQuerySupporter reviewQuerySupporter() { } @Bean - public RatingQuerySupporter ratingQuerySupporter() { - return new RatingQuerySupporter(); + public RatingQuerySupporter ratingQuerySupporter(@Lazy RegionRepository regionRepository) { + return new RatingQuerySupporter(regionRepository); } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java index c1e65e38d..67c8aff38 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/service/RegionServiceTest.java @@ -29,15 +29,16 @@ void testFindAll() { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키"), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", - "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지"), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키"), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산"), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키")); + "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", + 19L), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); // When when(regionQueryRepository.findAllRegionsResponse()).thenReturn(response); diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java index 699351ae5..ac20bb5bd 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java @@ -41,15 +41,16 @@ void docs_1() throws Exception { // given List response = List.of( - RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키"), + RegionsItem.of(1L, "스코틀랜드/로우랜드", "Scotland/Lowlands", "가벼운 맛이 특징인 로우랜드 위스키", 19L), RegionsItem.of( 2L, "스코틀랜드/하이랜드", "Scotland/Highlands", - "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지"), - RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키"), - RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산"), - RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키")); + "맛의 다양성이 특징인 하이랜드 위스키, 해안의 짠맛부터 달콤하고 과일 맛까지", + 19L), + RegionsItem.of(3L, "스코틀랜드/아일랜드", "Scotland/Ireland", "부드러운 맛이 특징인 아일랜드 위스키", 19L), + RegionsItem.of(11L, "프랑스", "France", "주로 브랜디와 와인 생산지로 유명하지만 위스키도 생산", null), + RegionsItem.of(12L, "스웨덴", "Sweden", "실험적인 방법으로 만드는 스웨덴 위스키", null)); // when when(referenceService.findAllRegion()).thenReturn(response); From 5b27339d04c6ffc4914bcdfdb456c68b214d92ae Mon Sep 17 00:00:00 2001 From: rlagu Date: Sat, 4 Apr 2026 23:32:12 +0900 Subject: [PATCH 14/31] test: update RegionServiceTest and RestReferenceControllerTest with parentId field addition Updated `RegionsItem` test data to include `parentId` field. Adjusted `ModuleConfig` to inject `RegionRepository` dependencies with `@Lazy` annotation for `AlcoholQuerySupporter` and `RatingQuerySupporter`. --- .../alcohols/AdminRegionControllerDocsTest.kt | 1 + .../operation/utils/DataInitializer.java | 121 +++++++++--------- .../alcohols/RestReferenceControllerTest.java | 4 + 3 files changed, 68 insertions(+), 58 deletions(-) diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt index 442ff0d0d..337ced4fa 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminRegionControllerDocsTest.kt @@ -77,6 +77,7 @@ class AdminRegionControllerDocsTest { fieldWithPath("data[].description").type(JsonFieldType.STRING).description("설명"), fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), fieldWithPath("data[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data[].parentId").type(JsonFieldType.NUMBER).description("상위 지역 ID").optional(), fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java index 0583e9b70..466e58b43 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java @@ -1,82 +1,87 @@ package app.bottlenote.operation.utils; -import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW; - import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + import java.util.ArrayList; import java.util.List; import java.util.Set; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; + +import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW; @Slf4j @Component @SuppressWarnings("unchecked") public class DataInitializer { - private static final String OFF_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = false"; - private static final String ON_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = true"; - private static final String TRUNCATE_SQL_FORMAT = "TRUNCATE %s"; - private static final List truncationDMLs = new ArrayList<>(); + private static final String OFF_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = false"; + private static final String ON_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = true"; + private static final String TRUNCATE_SQL_FORMAT = "TRUNCATE %s"; + private static final List truncationDMLs = new ArrayList<>(); - private static volatile boolean initialized = false; + private static volatile boolean initialized = false; - private static final Set SYSTEM_TABLE_PREFIXES = - Set.of("flyway_", "databasechangelog", "schema_version"); + private static final Set SYSTEM_TABLE_PREFIXES = + Set.of("flyway_", "databasechangelog", "schema_version"); - @PersistenceContext private EntityManager em; + @PersistenceContext + private EntityManager em; - protected DataInitializer() {} + protected DataInitializer() { + } - @Transactional(value = REQUIRES_NEW) - public void deleteAll() { - if (!initialized) { - initCache(); - } - log.info("데이터 초기화 시작"); - em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); - truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); - em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); - } + @Transactional(value = REQUIRES_NEW) + public void deleteAll() { + if (!initialized) { + initCache(); + } + log.info("데이터 초기화 시작"); + em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); + truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); + em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); + log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); + } - /** 캐시를 강제로 재초기화 후 전체 데이터 삭제 (테스트에서 동적 테이블 생성 시 사용) */ - @Transactional(value = REQUIRES_NEW) - public void refreshCache() { - log.info("데이터 초기화 시작 (캐시 재초기화)"); - synchronized (truncationDMLs) { - truncationDMLs.clear(); - init(); - initialized = true; - } - em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); - truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); - em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); - } + /** + * 캐시를 강제로 재초기화 후 전체 데이터 삭제 (테스트에서 동적 테이블 생성 시 사용) + */ + @Transactional(value = REQUIRES_NEW) + public void refreshCache() { + log.info("데이터 초기화 시작 (캐시 재초기화)"); + synchronized (truncationDMLs) { + truncationDMLs.clear(); + init(); + initialized = true; + } + em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); + truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); + em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); + log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); + } - private void initCache() { - if (!initialized) { - synchronized (truncationDMLs) { - if (!initialized) { - init(); - initialized = true; - } - } - } - } + private void initCache() { + if (!initialized) { + synchronized (truncationDMLs) { + if (!initialized) { + init(); + initialized = true; + } + } + } + } - private void init() { - final List tableNames = em.createNativeQuery("SHOW TABLES ").getResultList(); - tableNames.stream() - .filter(tableName -> !isSystemTable((String) tableName)) - .map(tableName -> String.format(TRUNCATE_SQL_FORMAT, tableName)) - .forEach(truncationDMLs::add); - } + private void init() { + final List tableNames = em.createNativeQuery("SHOW TABLES ").getResultList(); + tableNames.stream() + .filter(tableName -> !isSystemTable((String) tableName)) + .map(tableName -> String.format(TRUNCATE_SQL_FORMAT, tableName)) + .forEach(truncationDMLs::add); + } - private boolean isSystemTable(String tableName) { - return SYSTEM_TABLE_PREFIXES.stream().anyMatch(prefix -> tableName.startsWith(prefix)); - } + private boolean isSystemTable(String tableName) { + return SYSTEM_TABLE_PREFIXES.stream().anyMatch(prefix -> tableName.startsWith(prefix)); + } } diff --git a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java index ac20bb5bd..d37ca0bd7 100644 --- a/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java +++ b/bottlenote-product-api/src/test/java/app/docs/alcohols/RestReferenceControllerTest.java @@ -68,6 +68,10 @@ void docs_1() throws Exception { fieldWithPath("data[].korName").description("지역 한글명"), fieldWithPath("data[].engName").description("지역 이름"), fieldWithPath("data[].description").description("지역 설명"), + fieldWithPath("data[].parentId") + .type(JsonFieldType.NUMBER) + .description("상위 지역 ID") + .optional(), fieldWithPath("errors") .type(JsonFieldType.ARRAY) .description("응답 성공 여부가 false일 경우 에러 메시지(없을 경우 null)"), From 92b0e060ced4d8a582e45d8f7b141c18de0d6223 Mon Sep 17 00:00:00 2001 From: rlagu Date: Sun, 5 Apr 2026 00:01:22 +0900 Subject: [PATCH 15/31] test: add integration tests for region hierarchy-based alcohol filtering Added tests to verify region hierarchy filtering in `AlcoholQueryIntegrationTest`. Ensured that parent region queries include child regions and child region queries are scoped correctly. --- .../operation/utils/DataInitializer.java | 7 +- .../AlcoholQueryIntegrationTest.java | 93 +++++++++++++++++++ plan/test-security-auth.md | 90 ++++++++++++++---- 3 files changed, 166 insertions(+), 24 deletions(-) diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java index 466e58b43..8ecd74f97 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java @@ -38,11 +38,10 @@ public void deleteAll() { if (!initialized) { initCache(); } - log.info("데이터 초기화 시작"); + log.debug("데이터 초기화 시작"); em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); } /** @@ -50,7 +49,7 @@ public void deleteAll() { */ @Transactional(value = REQUIRES_NEW) public void refreshCache() { - log.info("데이터 초기화 시작 (캐시 재초기화)"); + log.debug("데이터 초기화 시작 (캐시 재초기화)"); synchronized (truncationDMLs) { truncationDMLs.clear(); init(); @@ -59,7 +58,7 @@ public void refreshCache() { em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - log.info("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); + log.debug("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); } private void initCache() { diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java index 302460842..2010c89c2 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/alcohols/integration/AlcoholQueryIntegrationTest.java @@ -8,9 +8,12 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import app.bottlenote.IntegrationTestSupport; +import app.bottlenote.alcohols.constant.AlcoholType; import app.bottlenote.alcohols.domain.Alcohol; import app.bottlenote.alcohols.domain.AlcoholQueryRepository; import app.bottlenote.alcohols.domain.AlcoholsTastingTags; +import app.bottlenote.alcohols.domain.Distillery; +import app.bottlenote.alcohols.domain.Region; import app.bottlenote.alcohols.domain.TastingTag; import app.bottlenote.alcohols.dto.response.AlcoholDetailResponse; import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse; @@ -458,4 +461,94 @@ void test_13() throws Exception { firstPageIdSet.contains(secondPageId), "페이지 간 중복 데이터가 발생했습니다. 중복 ID: " + secondPageId); } } + + @Test + @DisplayName("부모 지역(스코틀랜드/전체)으로 검색하면 하위 지역 위스키도 함께 조회된다.") + void test_14_부모_지역으로_검색시_하위_지역_포함() throws Exception { + // given - 부모 지역과 하위 지역 생성 + Region parentRegion = alcoholTestFactory.persistRegion("스코틀랜드/전체", "Scotland"); + Region childRegion1 = + alcoholTestFactory.persistRegion( + Region.builder().korName("스페이사이드").engName("Speyside").parent(parentRegion)); + Region childRegion2 = + alcoholTestFactory.persistRegion( + Region.builder().korName("아일라").engName("Islay").parent(parentRegion)); + Region unrelatedRegion = alcoholTestFactory.persistRegion("일본", "Japan"); + + Distillery distillery = alcoholTestFactory.persistDistillery(); + + // 하위 지역에 위스키 생성 + Alcohol speysideWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, childRegion1, distillery); + Alcohol islayWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, childRegion2, distillery); + // 부모 지역 자체에도 위스키 생성 + Alcohol scotlandWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, parentRegion, distillery); + // 무관한 지역에 위스키 생성 + Alcohol japanWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, unrelatedRegion, distillery); + + // when - 부모 지역 ID로 검색 + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/alcohols/search") + .param("regionId", String.valueOf(parentRegion.getId())) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + AlcoholSearchResponse responseData = extractData(result, AlcoholSearchResponse.class); + + assertNotNull(responseData); + assertEquals(3, responseData.getTotalCount(), "부모 + 하위 지역 위스키 3개가 조회되어야 한다"); + + Set resultIds = + responseData.getAlcohols().stream() + .map(AlcoholsSearchItem::getAlcoholId) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(resultIds.contains(scotlandWhisky.getId()), "부모 지역 위스키가 포함되어야 한다"); + assertTrue(resultIds.contains(speysideWhisky.getId()), "스페이사이드 위스키가 포함되어야 한다"); + assertTrue(resultIds.contains(islayWhisky.getId()), "아일라 위스키가 포함되어야 한다"); + assertFalse(resultIds.contains(japanWhisky.getId()), "일본 위스키는 포함되지 않아야 한다"); + } + + @Test + @DisplayName("하위 지역으로 검색하면 해당 지역 위스키만 조회된다.") + void test_15_하위_지역으로_검색시_해당_지역만() throws Exception { + // given + Region parentRegion = alcoholTestFactory.persistRegion("스코틀랜드/전체", "Scotland"); + Region childRegion = + alcoholTestFactory.persistRegion( + Region.builder().korName("스페이사이드").engName("Speyside").parent(parentRegion)); + + Distillery distillery = alcoholTestFactory.persistDistillery(); + + Alcohol parentWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, parentRegion, distillery); + Alcohol childWhisky = + alcoholTestFactory.persistAlcohol(AlcoholType.WHISKY, childRegion, distillery); + + // when - 하위 지역 ID로 검색 + MvcTestResult result = + mockMvcTester + .get() + .uri("/api/v1/alcohols/search") + .param("regionId", String.valueOf(childRegion.getId())) + .contentType(APPLICATION_JSON) + .header("Authorization", "Bearer " + getToken()) + .with(csrf()) + .exchange(); + + // then + AlcoholSearchResponse responseData = extractData(result, AlcoholSearchResponse.class); + + assertNotNull(responseData); + assertEquals(1, responseData.getTotalCount(), "하위 지역 위스키 1개만 조회되어야 한다"); + assertEquals(childWhisky.getId(), responseData.getAlcohols().getFirst().getAlcoholId()); + } } diff --git a/plan/test-security-auth.md b/plan/test-security-auth.md index 3e23d61d0..871bcdc89 100644 --- a/plan/test-security-auth.md +++ b/plan/test-security-auth.md @@ -5,6 +5,7 @@ ### 보안의 특수성 보안은 일반 기능과 근본적으로 다릅니다: + - 일반 기능 버그: 사용자 불편, 데이터 오류 - 보안 버그: 시스템 전체 침해, 개인정보 유출, 법적 책임 @@ -14,13 +15,14 @@ ```java public String generateToken(User user) { - return jwtBuilder.build(user); + return jwtBuilder.build(user); } ``` 이 코드는 항상 토큰을 생성합니다. "잘 되네요!"라고 생각하기 쉽습니다. 하지만: + - 만료 시간이 제대로 설정되었나? - 토큰 서명이 올바른가? - 리프레시 토큰과 액세스 토큰의 차이가 있나? @@ -31,6 +33,7 @@ public String generateToken(User user) { ### 테스트 = 보안 점검표 보안 테스트는 단순한 코드 검증이 아닙니다: + - 정상 케이스: "로그인이 되는가?" - 공격 케이스: "만료 토큰으로 접근하면 거부되는가?" - 위조 케이스: "서명이 틀린 토큰으로 접근하면 거부되는가?" @@ -40,6 +43,7 @@ public String generateToken(User user) { ### 규제 및 감사 대비 개인정보보호법, 금융권 보안 감사 등에서 요구하는 것: + - "인증 로직이 검증되었습니까?" - "보안 테스트 결과를 제출하세요" @@ -48,6 +52,7 @@ public String generateToken(User user) { ### 라이브러리 업데이트의 안전망 보안 라이브러리는 자주 업데이트됩니다: + - JWT 라이브러리 취약점 발견 → 업데이트 필요 - 테스트 없음 → "업데이트하면 뭐가 깨질까?" → 못 함 - 테스트 있음 → 업데이트 후 테스트 실행 → 안전하게 확인 @@ -56,31 +61,31 @@ public String generateToken(User user) { ### JWT 관련 컴포넌트 -| 컴포넌트 | 위치 | 책임 | 테스트 상태 | -|---------|------|------|------------| -| JwtTokenProvider | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 토큰 생성 | 0% | -| JwtTokenValidator | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 토큰 검증 | 0% | -| AppleTokenValidator | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | Apple 토큰 검증 | 0% | -| JwtAuthenticationFilter | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 요청 필터링 | 0% | -| JwtAuthenticationManager | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 인증 관리 | 0% | -| JwtAuthenticationEntryPoint | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 인증 실패 처리 | 0% | +| 컴포넌트 | 위치 | 책임 | 테스트 상태 | +|-----------------------------|-------------------------------------------------------------------|-------------|--------| +| JwtTokenProvider | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 토큰 생성 | 0% | +| JwtTokenValidator | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 토큰 검증 | 0% | +| AppleTokenValidator | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | Apple 토큰 검증 | 0% | +| JwtAuthenticationFilter | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 요청 필터링 | 0% | +| JwtAuthenticationManager | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 인증 관리 | 0% | +| JwtAuthenticationEntryPoint | bottlenote-mono/src/main/java/app/bottlenote/global/security/jwt/ | 인증 실패 처리 | 0% | ### OAuth 인증 서비스 -| 컴포넌트 | 위치 | 책임 | 테스트 상태 | -|---------|------|------|------------| -| KakaoAuthService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | 카카오 OAuth | 0% | -| AppleAuthService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | Apple OAuth | 0% | -| NonceService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | Nonce 관리 | 0% | -| KakaoFeignClient | bottlenote-mono/src/main/java/app/bottlenote/user/client/ | 카카오 API 호출 | 0% | +| 컴포넌트 | 위치 | 책임 | 테스트 상태 | +|------------------|------------------------------------------------------------|-------------|--------| +| KakaoAuthService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | 카카오 OAuth | 0% | +| AppleAuthService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | Apple OAuth | 0% | +| NonceService | bottlenote-mono/src/main/java/app/bottlenote/user/service/ | Nonce 관리 | 0% | +| KakaoFeignClient | bottlenote-mono/src/main/java/app/bottlenote/user/client/ | 카카오 API 호출 | 0% | ### Security 설정 -| 컴포넌트 | 위치 | 책임 | 테스트 상태 | -|---------|------|------|------------| -| CustomUserDetailsService | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 사용자 로드 | 0% | -| SecurityContextUtil | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 컨텍스트 관리 | 0% | -| SecurityConfig | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 보안 설정 | 0% | +| 컴포넌트 | 위치 | 책임 | 테스트 상태 | +|--------------------------|---------------------------------------------------------------|---------|--------| +| CustomUserDetailsService | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 사용자 로드 | 0% | +| SecurityContextUtil | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 컨텍스트 관리 | 0% | +| SecurityConfig | bottlenote-mono/src/main/java/app/bottlenote/global/security/ | 보안 설정 | 0% | ### 커버리지 현황 @@ -93,6 +98,7 @@ public String generateToken(User user) { ### 목표 설정 근거 **왜 90%인가?** + - 보안은 한 번 뚫리면 치명적이므로 높은 커버리지 필수 - 핵심 인증 로직 (JwtTokenProvider, JwtTokenValidator)은 95% 목표 - 일부 설정 코드 및 예외 처리 경로는 제외 가능 @@ -103,6 +109,7 @@ public String generateToken(User user) { ### 위험도 평가 현재 상태는 다음과 같은 위험을 내포합니다: + - JWT 만료 검증이 올바른지 확인 불가 - OAuth 인증 흐름의 정확성 확인 불가 - 보안 설정 변경 시 영향 범위 파악 불가 @@ -113,16 +120,19 @@ public String generateToken(User user) { ### P0: 최우선 작성 대상 **JwtTokenProvider** + - 이유: 모든 인증의 시작점, 토큰 생성 로직 - 위험도: 매우 높음 (잘못된 토큰 생성 시 전체 인증 실패) - 복잡도: 중간 **JwtTokenValidator** + - 이유: 토큰 검증의 핵심, 보안의 마지막 방어선 - 위험도: 매우 높음 (검증 실패 시 무단 접근 허용) - 복잡도: 높음 **AppleTokenValidator** + - 이유: Apple OAuth의 핵심, 외부 의존성 - 위험도: 높음 (Apple 인증 사용자 접근 불가) - 복잡도: 높음 (외부 API 의존) @@ -130,16 +140,19 @@ public String generateToken(User user) { ### P1: 중요 작성 대상 **KakaoAuthService** + - 이유: 카카오 OAuth 핵심 로직 - 위험도: 높음 - 복잡도: 중간 **AppleAuthService** + - 이유: Apple OAuth 핵심 로직 - 위험도: 높음 - 복잡도: 중간 **CustomUserDetailsService** + - 이유: Spring Security 사용자 로드 - 위험도: 중간 - 복잡도: 낮음 @@ -147,11 +160,13 @@ public String generateToken(User user) { ### P2: 보통 작성 대상 **NonceService** + - 이유: Nonce 관리 - 위험도: 중간 - 복잡도: 낮음 **SecurityContextUtil** + - 이유: 컨텍스트 관리 유틸 - 위험도: 낮음 - 복잡도: 낮음 @@ -165,21 +180,25 @@ public String generateToken(User user) { #### 액세스 토큰 생성 **시나리오 1: 정상적인 액세스 토큰 생성** + - 시나리오: 유효한 사용자 정보로 액세스 토큰을 생성할 때 올바른 토큰이 생성되어야 한다 - 왜: 기본 동작 검증, 토큰 생성의 정확성 보장 - 테스트 방법: 사용자 정보 전달 후 토큰 생성, 토큰 파싱하여 클레임 검증 **시나리오 2: 만료 시간 설정** + - 시나리오: 생성된 액세스 토큰의 만료 시간이 24시간으로 설정되어야 한다 - 왜: 토큰 수명 정책 준수 확인 - 테스트 방법: 토큰 생성 후 만료 시간 클레임 확인 **시나리오 3: 사용자 정보 포함** + - 시나리오: 토큰에 사용자 ID, 권한 등 필수 정보가 포함되어야 한다 - 왜: 토큰으로부터 사용자 식별 가능성 보장 - 테스트 방법: 토큰 파싱 후 사용자 정보 클레임 검증 **시나리오 4: null 사용자 정보 처리** + - 시나리오: null 사용자 정보로 토큰 생성 시도 시 예외가 발생해야 한다 - 왜: 잘못된 입력 방어 - 테스트 방법: null 전달, 예외 발생 확인 @@ -187,16 +206,19 @@ public String generateToken(User user) { #### 리프레시 토큰 생성 **시나리오 1: 정상적인 리프레시 토큰 생성** + - 시나리오: 유효한 사용자 정보로 리프레시 토큰을 생성할 때 올바른 토큰이 생성되어야 한다 - 왜: 리프레시 토큰 기능 검증 - 테스트 방법: 사용자 정보 전달 후 리프레시 토큰 생성, 검증 **시나리오 2: 만료 시간 설정** + - 시나리오: 생성된 리프레시 토큰의 만료 시간이 30일로 설정되어야 한다 - 왜: 리프레시 토큰 수명 정책 준수 확인 - 테스트 방법: 토큰 생성 후 만료 시간 클레임 확인 **시나리오 3: 액세스 토큰과 구분** + - 시나리오: 리프레시 토큰과 액세스 토큰이 명확히 구분되어야 한다 - 왜: 토큰 타입 혼동 방지 - 테스트 방법: 두 토큰 생성 후 타입 클레임 또는 만료 시간으로 구분 확인 @@ -208,31 +230,37 @@ public String generateToken(User user) { #### 토큰 검증 **시나리오 1: 유효한 토큰 검증 성공** + - 시나리오: 올바르게 생성된 토큰을 검증할 때 성공해야 한다 - 왜: 정상 케이스 검증 - 테스트 방법: Provider로 토큰 생성 후 Validator로 검증, 성공 확인 **시나리오 2: 만료된 토큰 거부** + - 시나리오: 만료된 토큰을 검증할 때 실패해야 한다 - 왜: 보안의 핵심, 만료 토큰으로 접근 방지 - 테스트 방법: 만료 시간이 과거인 토큰 생성 후 검증, 실패 확인 **시나리오 3: 서명이 틀린 토큰 거부** + - 시나리오: 서명이 변조된 토큰을 검증할 때 실패해야 한다 - 왜: 위조 토큰 방어 - 테스트 방법: 토큰 서명 부분 변조 후 검증, 실패 확인 **시나리오 4: 잘못된 형식의 토큰 거부** + - 시나리오: JWT 형식이 아닌 문자열을 검증할 때 실패해야 한다 - 왜: 잘못된 입력 방어 - 테스트 방법: "invalid-token" 같은 문자열로 검증, 실패 확인 **시나리오 5: null 또는 빈 토큰 거부** + - 시나리오: null 또는 빈 문자열 토큰을 검증할 때 실패해야 한다 - 왜: null 안전성 보장 - 테스트 방법: null, "" 등으로 검증, 실패 확인 **시나리오 6: 사용자 정보 추출** + - 시나리오: 유효한 토큰에서 사용자 정보를 정확히 추출해야 한다 - 왜: 토큰으로부터 인증 정보 획득 - 테스트 방법: 토큰 검증 후 사용자 ID, 권한 등 추출, 원본과 일치 확인 @@ -240,16 +268,19 @@ public String generateToken(User user) { #### 토큰 갱신 **시나리오 1: 리프레시 토큰으로 액세스 토큰 갱신** + - 시나리오: 유효한 리프레시 토큰으로 새 액세스 토큰을 발급받을 수 있어야 한다 - 왜: 토큰 갱신 기능 검증 - 테스트 방법: 리프레시 토큰으로 갱신 요청, 새 액세스 토큰 발급 확인 **시나리오 2: 만료된 리프레시 토큰으로 갱신 거부** + - 시나리오: 만료된 리프레시 토큰으로 갱신 시도 시 실패해야 한다 - 왜: 만료된 리프레시 토큰 방어 - 테스트 방법: 만료된 리프레시 토큰으로 갱신, 실패 확인 **시나리오 3: 액세스 토큰으로 갱신 시도 거부** + - 시나리오: 액세스 토큰으로 갱신 시도 시 실패해야 한다 - 왜: 토큰 타입 혼동 방지 - 테스트 방법: 액세스 토큰으로 갱신 요청, 실패 확인 @@ -261,26 +292,31 @@ public String generateToken(User user) { #### Apple ID 토큰 검증 **시나리오 1: 유효한 Apple ID 토큰 검증** + - 시나리오: Apple에서 발급한 유효한 ID 토큰을 검증할 때 성공해야 한다 - 왜: Apple 인증 기본 동작 검증 - 테스트 방법: Mock Apple 토큰으로 검증, 성공 확인 **시나리오 2: 만료된 Apple 토큰 거부** + - 시나리오: 만료된 Apple ID 토큰을 검증할 때 실패해야 한다 - 왜: 만료 토큰 방어 - 테스트 방법: 만료된 토큰으로 검증, 실패 확인 **시나리오 3: Apple 공개키로 서명 검증** + - 시나리오: Apple 공개키로 토큰 서명을 검증해야 한다 - 왜: 위조 토큰 방지 - 테스트 방법: 잘못된 서명의 토큰으로 검증, 실패 확인 **시나리오 4: nonce 검증** + - 시나리오: 토큰의 nonce가 요청 시 전달한 nonce와 일치해야 한다 - 왜: 재생 공격 방지 - 테스트 방법: 다른 nonce로 검증, 실패 확인 **시나리오 5: audience 검증** + - 시나리오: 토큰의 audience가 우리 앱의 client ID와 일치해야 한다 - 왜: 다른 앱용 토큰 거부 - 테스트 방법: 다른 audience의 토큰으로 검증, 실패 확인 @@ -292,21 +328,25 @@ public String generateToken(User user) { #### 카카오 인증 **시나리오 1: 카카오 액세스 토큰으로 사용자 정보 조회** + - 시나리오: 유효한 카카오 액세스 토큰으로 사용자 정보를 조회할 수 있어야 한다 - 왜: 카카오 인증 기본 기능 검증 - 테스트 방법: Mock 카카오 API 응답 설정 후 조회, 사용자 정보 확인 **시나리오 2: 유효하지 않은 토큰 처리** + - 시나리오: 유효하지 않은 카카오 토큰으로 조회 시 예외가 발생해야 한다 - 왜: 잘못된 토큰 방어 - 테스트 방법: 잘못된 토큰으로 조회, 예외 발생 확인 **시나리오 3: 카카오 API 오류 처리** + - 시나리오: 카카오 API가 오류를 반환할 때 적절히 처리해야 한다 - 왜: 외부 서비스 장애 대응 - 테스트 방법: Mock API 오류 응답 설정 후 조회, 예외 처리 확인 **시나리오 4: 사용자 정보 매핑** + - 시나리오: 카카오 응답을 우리 User 엔티티로 정확히 매핑해야 한다 - 왜: 데이터 정확성 보장 - 테스트 방법: Mock 카카오 응답으로 조회, 매핑 결과 검증 @@ -318,16 +358,19 @@ public String generateToken(User user) { #### Apple 인증 **시나리오 1: Apple ID 토큰으로 사용자 정보 조회** + - 시나리오: 유효한 Apple ID 토큰으로 사용자 정보를 조회할 수 있어야 한다 - 왜: Apple 인증 기본 기능 검증 - 테스트 방법: Mock Apple 토큰으로 조회, 사용자 정보 확인 **시나리오 2: 토큰 검증 실패 처리** + - 시나리오: Apple 토큰 검증 실패 시 예외가 발생해야 한다 - 왜: 잘못된 토큰 방어 - 테스트 방법: 검증 실패하도록 설정 후 조회, 예외 확인 **시나리오 3: 사용자 정보 매핑** + - 시나리오: Apple 토큰의 클레임을 우리 User 엔티티로 정확히 매핑해야 한다 - 왜: 데이터 정확성 보장 - 테스트 방법: Mock Apple 토큰으로 조회, 매핑 결과 검증 @@ -339,21 +382,25 @@ public String generateToken(User user) { #### Nonce 관리 **시나리오 1: Nonce 생성** + - 시나리오: 새로운 Nonce를 생성할 때 유일한 값이어야 한다 - 왜: 재생 공격 방지 - 테스트 방법: 여러 번 생성 후 모두 다른 값 확인 **시나리오 2: Nonce 검증 성공** + - 시나리오: 생성된 Nonce를 검증할 때 성공해야 한다 - 왜: 정상 케이스 검증 - 테스트 방법: 생성 후 즉시 검증, 성공 확인 **시나리오 3: 사용된 Nonce 재사용 거부** + - 시나리오: 이미 사용된 Nonce를 다시 사용할 때 실패해야 한다 - 왜: 재생 공격 방지 - 테스트 방법: 검증 후 재검증, 실패 확인 **시나리오 4: 만료된 Nonce 거부** + - 시나리오: 만료된 Nonce를 검증할 때 실패해야 한다 - 왜: 오래된 요청 거부 - 테스트 방법: 시간 경과 후 검증, 실패 확인 @@ -365,16 +412,19 @@ public String generateToken(User user) { #### 사용자 로드 **시나리오 1: 사용자 ID로 UserDetails 로드** + - 시나리오: 존재하는 사용자 ID로 UserDetails를 로드할 수 있어야 한다 - 왜: Spring Security 연동 기본 기능 - 테스트 방법: 사용자 ID로 loadUserByUsername 호출, UserDetails 반환 확인 **시나리오 2: 존재하지 않는 사용자 처리** + - 시나리오: 존재하지 않는 사용자 ID로 로드 시 예외가 발생해야 한다 - 왜: 잘못된 사용자 방어 - 테스트 방법: 존재하지 않는 ID로 호출, UsernameNotFoundException 확인 **시나리오 3: 권한 매핑** + - 시나리오: User 엔티티의 role을 GrantedAuthority로 정확히 매핑해야 한다 - 왜: 권한 기반 접근 제어 보장 - 테스트 방법: 로드 후 권한 목록 확인 From 1d881a2fd937cc805dc703f3d0f14c08291a5a38 Mon Sep 17 00:00:00 2001 From: rlagu Date: Sun, 5 Apr 2026 00:07:09 +0900 Subject: [PATCH 16/31] feat: add region hierarchy support for filtering and API enhancements Introduced `parentId` self-reference to `Region` entity for parent-child hierarchy. Updated DB schema, repositories, and query logic to support filtering by child regions when a parent is selected. Enhanced APIs and DTOs to include `parentId`, and updated tests to validate the new functionality. Added relevant caching and documentation updates. --- .gitignore | 1 + plan/complete/region-hierarchy.md | 141 ++++++++++++++++++++++++++ plan/region-hierarchy.md | 161 ------------------------------ 3 files changed, 142 insertions(+), 161 deletions(-) create mode 100644 plan/complete/region-hierarchy.md delete mode 100644 plan/region-hierarchy.md diff --git a/.gitignore b/.gitignore index 41cb2602f..b8cb07db2 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,4 @@ docs/_site/ # 기존 Jekyll 빌드 결과물 (Antora 전환 후 삭제 예정) docs/admin-api.html docs/index.html +*/settings.local.json diff --git a/plan/complete/region-hierarchy.md b/plan/complete/region-hierarchy.md new file mode 100644 index 000000000..77c5b4d0c --- /dev/null +++ b/plan/complete/region-hierarchy.md @@ -0,0 +1,141 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-04-05 + +** Core Achievements ** +- Region 엔티티에 parent_id Self-Reference 추가 (FK 기반 부모-자식 관계) +- 부모 지역(스코틀랜드/전체) 검색 시 하위 6개 지역 위스키 포함 조회 +- 별도 클래스 생성 없이 QuerySupporter 내부에서 하위 지역 해석 처리 + +** Key Components ** +- Region.java: parent 필드 (ManyToOne self-reference) +- AlcoholQuerySupporter / RatingQuerySupporter / UserQuerySupporter: eqRegion 내부에서 RegionRepository.findChildRegionIds 호출 +- RegionsItem / AdminRegionItem: parentId 필드 추가 + +** Deferred Items ** +- id=20 "스코트랜드" 오타 수정: 별도 작업으로 처리 +- 초기 데이터(04-data-region.sql) 미수정: Liquibase changelog + 수동 쿼리로 대체 +================================================================================ +``` + +# Region 계층 구조 도입 + +## Context + +현재 Region(지역)은 플랫 구조로, 스코틀랜드 하위 지역(캠벨타운, 아일라, 스페이사이드 등)이 독립 레코드로 존재한다. +"스코틀랜드"(id=19)를 "스코틀랜드/전체"로 변경하고, 이를 선택하면 모든 스코틀랜드 하위 지역의 위스키가 함께 조회되도록 한다. +향후 다른 국가(미국, 일본 등)에도 동일 패턴을 적용할 수 있어야 한다. + +## 접근 방식 + +FK 기반 부모-자식 관계로 데이터 무결성을 보장하는 방식. +별도 Resolver 클래스 없이, QuerySupporter 내부에서 RegionRepository를 직접 주입받아 하위 지역을 해석한다. + +## 조회 동작 + +"스코틀랜드/전체"(id=19) 선택 시: id=19 자체 + 하위 지역(14,15,16,17,18,20) 위스키 모두 조회 + +--- + +## 수정 파일 목록 + +### DB 스키마 (Liquibase) + +| 파일 | 변경 | +|------|------| +| `git.environment-variables/storage/mysql/changelog/schema.mysql.sql` | changeset 2개 추가 | + +```sql +-- changeset rlagu:20260404-1 splitStatements:false +-- comment: regions 테이블에 parent_id 컬럼 추가 +ALTER TABLE regions ADD COLUMN parent_id BIGINT NULL COMMENT '상위 지역 ID'; + +-- changeset rlagu:20260404-2 splitStatements:false +-- comment: regions parent_id 인덱스 추가 +CREATE INDEX idx_regions_parent_id ON regions(parent_id); +``` + +수동 반영 (양쪽 DB에 직접 실행): +```sql +UPDATE regions SET kor_name = '스코틀랜드/전체', eng_name = 'Scotland' WHERE id = 19; +UPDATE regions SET parent_id = 19 WHERE id IN (14, 15, 16, 17, 18, 20); +``` + +### 엔티티 + Repository + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/domain/Region.java` | `parent` 필드 추가 (`@ManyToOne`, `@JoinColumn(name = "parent_id")`) | +| `bottlenote-mono/.../alcohols/domain/RegionRepository.java` | `findChildRegionIds(Long parentId)` 메서드 추가 | +| `bottlenote-mono/.../alcohols/repository/JpaRegionQueryRepository.java` | JPQL에 `r.parent.id` 추가, `findChildRegionIds` 구현 | + +### QuerySupporter (하위 지역 해석 내재화) + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/repository/AlcoholQuerySupporter.java` | RegionRepository 주입, `eqRegion` 내부에서 하위 지역 IN 절 처리 | +| `bottlenote-mono/.../rating/repository/RatingQuerySupporter.java` | RegionRepository 주입, `eqAlcoholRegion` 내부에서 하위 지역 IN 절 처리 | +| `bottlenote-mono/.../user/repository/UserQuerySupporter.java` | RegionRepository 주입, `eqRegion` 내부에서 하위 지역 IN 절 처리 | + +변경 패턴 (3곳 동일, 시그니처 변경 없음): +```java +public BooleanExpression eqRegion(Long regionId) { + if (regionId == null) return null; + List childIds = regionRepository.findChildRegionIds(regionId); + if (childIds.isEmpty()) return alcohol.region.id.eq(regionId); + List regionIds = new ArrayList<>(childIds.size() + 1); + regionIds.add(regionId); + regionIds.addAll(childIds); + return alcohol.region.id.in(regionIds); +} +``` + +### API 응답 DTO + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../alcohols/dto/response/RegionsItem.java` | `parentId` 필드 추가 | +| `bottlenote-mono/.../alcohols/dto/response/AdminRegionItem.java` | `parentId` 필드 추가 | + +### 테스트 수정 + +| 파일 | 변경 | +|------|------| +| `bottlenote-mono/.../config/ModuleConfig.java` | QuerySupporter 빈 생성 시 RegionRepository 주입 | +| `bottlenote-product-api/.../docs/alcohols/RestReferenceControllerTest.java` | RegionsItem에 parentId 추가, RestDocs에 parentId 필드 문서화 | +| `bottlenote-product-api/.../alcohols/service/RegionServiceTest.java` | RegionsItem에 parentId 추가 | +| `bottlenote-product-api/.../alcohols/integration/AlcoholQueryIntegrationTest.java` | 부모/하위 지역 검색 통합 테스트 2개 추가 | +| `bottlenote-admin-api/.../helper/alcohols/AlcoholsHelper.kt` | AdminRegionItem에 parentId 추가 | +| `bottlenote-admin-api/.../docs/alcohols/AdminRegionControllerDocsTest.kt` | RestDocs에 parentId 필드 문서화 | + +--- + +## 영향받는 API 목록 + +| API | 엔드포인트 | +|------|----------| +| 알코올 검색 | `GET /api/v1/alcohols/search?regionId=19` | +| 별점 평가 목록 | `GET /api/v1/rating?regionId=19` | +| MyBottle (Review) | `GET /api/v1/my-page/{userId}/my-bottle/reviews?regionId=19` | +| MyBottle (Rating) | `GET /api/v1/my-page/{userId}/my-bottle/ratings?regionId=19` | +| MyBottle (Picks) | `GET /api/v1/my-page/{userId}/my-bottle/picks?regionId=19` | +| Region 목록 | `GET /api/v1/regions` (parentId 필드 추가) | +| Admin Region 목록 | `GET /admin/api/v1/regions` (parentId 필드 추가) | +| Admin 알코올 검색 | Admin API 내 알코올 검색 | + +--- + +## 검증 결과 + +| 검증 항목 | 결과 | +|-----------|------| +| `./gradlew compileJava compileTestJava` | 통과 | +| `./gradlew spotlessCheck` | 통과 | +| `./gradlew unit_test` | 통과 | +| `./gradlew check_rule_test` | 통과 | +| `./gradlew restDocsTest` | 통과 (product + admin) | +| `./gradlew integration_test` | 통과 | +| `./gradlew admin_integration_test` | 통과 | diff --git a/plan/region-hierarchy.md b/plan/region-hierarchy.md deleted file mode 100644 index e722f41d8..000000000 --- a/plan/region-hierarchy.md +++ /dev/null @@ -1,161 +0,0 @@ -# Region 계층 구조 도입 계획 - -## Context - -현재 Region(지역)은 플랫 구조로, 스코틀랜드 하위 지역(캠벨타운, 아일라, 스페이사이드 등)이 독립 레코드로 존재한다. -"스코틀랜드"(id=19)를 "스코틀랜드/전체"로 변경하고, 이를 선택하면 모든 스코틀랜드 하위 지역의 위스키가 함께 조회되도록 한다. -향후 다른 국가(미국, 일본 등)에도 동일 패턴을 적용할 수 있어야 한다. - -## 접근 방식: Region 엔티티에 parentId Self-Reference 추가 - -이름 기반 LIKE 검색(id=20 "스코트랜드" 오타 문제로 불안정)이나 groupCode(parentId의 열화판) 대신, -FK 기반 부모-자식 관계로 데이터 무결성을 보장하는 방식을 선택한다. - -## 작업 범위 - -- 코드 변경 + Liquibase changelog 추가 -- 초기 데이터(04-data-region.sql) 수정하지 않음 -- id=20 오타 수정하지 않음 - -## 조회 동작 - -"스코틀랜드/전체"(id=19) 선택 시: id=19 자체 + 하위 지역(14,15,16,17,18,20) 위스키 모두 조회 - ---- - -## 수정 파일 목록 - -### Phase 1: DB 스키마 + 엔티티 - -> [완료] 2026-04-04: DB 스키마 변경 반영 (production + development) -> - changeset `rlagu:20260404-1`: parent_id 컬럼 추가 -> - changeset `rlagu:20260404-2`: idx_regions_parent_id 인덱스 추가 -> - 스코틀랜드 데이터 UPDATE: 수동 쿼리로 양쪽 DB에 반영 완료 - -| 파일 | 변경 | -|------|------| -| `git.environment-variables/storage/mysql/changelog/schema.mysql.sql` | parent_id 컬럼 + 인덱스 changeset 추가, 스코틀랜드 데이터 UPDATE | -| `bottlenote-mono/.../alcohols/domain/Region.java` | `parent` 필드 (ManyToOne self-reference) 추가 | - -**changelog 추가 내용:** -```sql --- changeset rlagu:20260404-1 splitStatements:false --- comment: regions 테이블에 parent_id 컬럼 추가 -ALTER TABLE regions ADD COLUMN parent_id BIGINT NULL COMMENT '상위 지역 ID'; - --- changeset rlagu:20260404-2 splitStatements:false --- comment: regions parent_id 인덱스 추가 -CREATE INDEX idx_regions_parent_id ON regions(parent_id); -``` - -**수동 반영 (양쪽 DB에 직접 실행):** -```sql -UPDATE regions SET kor_name = '스코틀랜드/전체', eng_name = 'Scotland' WHERE id = 19; -UPDATE regions SET parent_id = 19 WHERE id IN (14, 15, 16, 17, 18, 20); -``` - -**Region.java 변경:** -```java -@ManyToOne(fetch = FetchType.LAZY) -@JoinColumn(name = "parent_id") -@Comment("상위 지역") -private Region parent; -``` - -### Phase 2: Repository 계층 - -| 파일 | 변경 | -|------|------| -| `bottlenote-mono/.../alcohols/domain/RegionRepository.java` | `findChildRegionIds(Long parentId)` 메서드 추가 | -| `bottlenote-mono/.../alcohols/repository/JpaRegionQueryRepository.java` | JPQL 구현 + findAllRegionsResponse에 parentId 추가 | - -### Phase 3: RegionIdResolver (신규) - -| 파일 | 변경 | -|------|------| -| `bottlenote-mono/.../alcohols/repository/RegionIdResolver.java` | **신규** - regionId -> List 변환 (부모면 자신+자식 반환) | -| `bottlenote-mono/.../global/cache/local/LocalCacheType.java` | `REGION_CHILDREN_CACHE` 추가 | - -**핵심 로직:** -```java -@Component -public class RegionIdResolver { - // regionId가 부모 Region이면: [regionId, childId1, childId2, ...] 반환 - // regionId가 자식 Region이면: [regionId] 반환 - // null이면: null 반환 - @Cacheable(value = "region_children_cache") - public List resolveRegionIds(Long regionId) { ... } -} -``` - -### Phase 4: QuerySupporter 시그니처 변경 (3개) - -| 파일 (라인) | 변경 | -|------|------| -| `bottlenote-mono/.../alcohols/repository/AlcoholQuerySupporter.java` (201) | `eqRegion(Long)` -> `eqRegion(List)`, IN 절 사용 | -| `bottlenote-mono/.../rating/repository/RatingQuerySupporter.java` (102) | `eqAlcoholRegion(Long)` -> `eqAlcoholRegion(List)`, IN 절 사용 | -| `bottlenote-mono/.../user/repository/UserQuerySupporter.java` (119) | `eqRegion(Long)` -> `eqRegion(List)`, IN 절 사용 | - -**변경 패턴 (3곳 동일):** -```java -// Before -public BooleanExpression eqRegion(Long regionId) { - if (regionId == null) return null; - return alcohol.region.id.eq(regionId); -} - -// After -public BooleanExpression eqRegion(List regionIds) { - if (regionIds == null || regionIds.isEmpty()) return null; - if (regionIds.size() == 1) return alcohol.region.id.eq(regionIds.get(0)); - return alcohol.region.id.in(regionIds); -} -``` - -### Phase 5: Repository 구현체에서 resolve 호출 (3개) - -| 파일 | 변경 | -|------|------| -| `bottlenote-mono/.../alcohols/repository/CustomAlcoholQueryRepositoryImpl.java` | RegionIdResolver 주입, 4곳에서 resolve 호출 (라인 99, 122, 329, 343) | -| `bottlenote-mono/.../rating/repository/CustomRatingQueryRepositoryImpl.java` | RegionIdResolver 주입, 2곳에서 resolve 호출 (라인 63, 85) | -| `bottlenote-mono/.../user/repository/CustomUserRepositoryImpl.java` | RegionIdResolver 주입, 6곳에서 resolve 호출 (라인 113, 185, 227, 253, 292, 323) | - -**호출 패턴 변경:** -```java -// Before -supporter.eqRegion(criteria.regionId()) - -// After -supporter.eqRegion(regionIdResolver.resolveRegionIds(criteria.regionId())) -``` - -### Phase 6: API 응답 DTO 수정 - -| 파일 | 변경 | -|------|------| -| `bottlenote-mono/.../alcohols/dto/response/RegionsItem.java` | `parentId` 필드 추가 | -| `bottlenote-mono/.../alcohols/dto/response/AdminRegionItem.java` | `parentId` 필드 추가 | - ---- - -## 영향받는 API 목록 - -| API | 엔드포인트 | -|------|----------| -| 알코올 검색 | `GET /api/v1/alcohols/search?regionId=19` | -| 별점 평가 목록 | `GET /api/v1/rating?regionId=19` | -| MyBottle (Review) | `GET /api/v1/my-page/{userId}/my-bottle/reviews?regionId=19` | -| MyBottle (Rating) | `GET /api/v1/my-page/{userId}/my-bottle/ratings?regionId=19` | -| MyBottle (Picks) | `GET /api/v1/my-page/{userId}/my-bottle/picks?regionId=19` | -| Region 목록 | `GET /api/v1/regions` (parentId 필드 추가) | -| Admin Region 목록 | `GET /admin/api/v1/regions` (parentId 필드 추가) | -| Admin 알코올 검색 | Admin API 내 알코올 검색 | - ---- - -## 검증 방법 - -1. **컴파일 확인**: `./gradlew compileJava` -2. **단위 테스트**: `./gradlew unit_test` - RegionsItem/AdminRegionItem 생성자 변경 영향 확인 -3. **통합 테스트**: `./gradlew integration_test` - Region 필터링이 사용되는 API 테스트 통과 확인 -4. **수동 검증**: regionId=19로 검색 시 하위 지역 위스키도 함께 반환되는지 확인 From c867228a3370bdf6cd484bfe2df71dd1d07e8a82 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 6 Apr 2026 13:34:47 +0900 Subject: [PATCH 17/31] chore: add PostToolUse hook for spotless and Kotlin spotless config Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.json | 12 ++++++++++++ build.gradle | 15 ++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.claude/settings.json b/.claude/settings.json index e06b0338e..60c1345ca 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,18 @@ } ] } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "FP=$(cat | jq -r '.tool_input.file_path // empty'); [[ \"$FP\" == *.java || \"$FP\" == *.kt ]] && cd $CLAUDE_PROJECT_DIR && ./gradlew spotlessApply -q 2>/dev/null || true", + "timeout": 30 + } + ] + } ] } } diff --git a/build.gradle b/build.gradle index 2a8f10b85..cf527a04a 100644 --- a/build.gradle +++ b/build.gradle @@ -76,7 +76,7 @@ subprojects { } } - // Spotless 설정 (mono, product-api에서 사용) + // Spotless 설정 (mono, product-api: Java / admin-api: Kotlin) if (project.name in ['bottlenote-mono', 'bottlenote-product-api']) { apply plugin: 'com.diffplug.spotless' @@ -89,6 +89,19 @@ subprojects { } } + if (project.name == 'bottlenote-admin-api') { + apply plugin: 'com.diffplug.spotless' + + spotless { + kotlin { + ktlint().editorConfigOverride([ + 'ktlint_standard_no-wildcard-imports': 'disabled' + ]) + target 'src/main/kotlin/**/*.kt', 'src/test/kotlin/**/*.kt' + } + } + } + // 서브모듈은 기본적으로 라이브러리 JAR 생성 bootJar.enabled = false jar.enabled = true From e284a7dbff09fd39be2441ef88dc36effb52519c Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 6 Apr 2026 13:46:17 +0900 Subject: [PATCH 18/31] style: apply ktlint formatting to admin-api Kotlin files Co-Authored-By: Claude Opus 4.6 (1M context) --- .../presentation/AdminAlcoholsController.kt | 40 +- .../presentation/AdminCurationController.kt | 113 +-- .../presentation/AdminDistilleryController.kt | 9 +- .../presentation/AdminRegionController.kt | 9 +- .../presentation/AdminTastingTagController.kt | 48 +- .../auth/config/RootAdminProperties.kt | 4 +- .../presentation/AdminBannerController.kt | 100 +- .../AdminImageUploadController.kt | 11 +- .../help/presentation/AdminHelpController.kt | 23 +- .../global/common/ApplicationReadyEvent.kt | 7 +- .../app/global/security/SecurityConfig.kt | 45 +- ...pplicationContextStartupIntegrationTest.kt | 1 - .../test/kotlin/app/IntegrationTestSupport.kt | 14 +- .../AdminAlcoholsControllerDocsTest.kt | 2 +- .../AdminTastingTagControllerDocsTest.kt | 16 +- .../banner/AdminBannerControllerDocsTest.kt | 808 +++++++-------- .../AdminCurationControllerDocsTest.kt | 950 +++++++++--------- .../app/helper/alcohols/AlcoholsHelper.kt | 140 +-- .../test/kotlin/app/helper/auth/AuthHelper.kt | 13 +- .../kotlin/app/helper/banner/BannerHelper.kt | 257 ++--- .../app/helper/curation/CurationHelper.kt | 154 +-- .../alcohols/AdminAlcoholsIntegrationTest.kt | 621 +++++++----- .../AdminReferenceDataIntegrationTest.kt | 76 +- .../AdminTastingTagIntegrationTest.kt | 258 ++--- .../auth/AdminAuthIntegrationTest.kt | 264 ++--- .../banner/AdminBannerIntegrationTest.kt | 841 ++++++++-------- .../curation/AdminCurationIntegrationTest.kt | 861 ++++++++-------- .../file/AdminImageUploadIntegrationTest.kt | 127 ++- .../help/AdminHelpIntegrationTest.kt | 150 +-- 29 files changed, 3079 insertions(+), 2883 deletions(-) diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt index b890f5a22..8efa8ac68 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminAlcoholsController.kt @@ -7,16 +7,7 @@ import app.bottlenote.alcohols.service.AlcoholQueryService import app.bottlenote.global.data.response.GlobalResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/alcohols") @@ -24,16 +15,15 @@ class AdminAlcoholsController( private val alcoholQueryService: AlcoholQueryService, private val adminAlcoholCommandService: AdminAlcoholCommandService ) { - @GetMapping - fun searchAlcohols(@ModelAttribute request: AdminAlcoholSearchRequest): ResponseEntity { - return ResponseEntity.ok(alcoholQueryService.searchAdminAlcohols(request)) - } + fun searchAlcohols( + @ModelAttribute request: AdminAlcoholSearchRequest + ): ResponseEntity = ResponseEntity.ok(alcoholQueryService.searchAdminAlcohols(request)) @GetMapping("/{alcoholId}") - fun getAlcoholDetail(@PathVariable alcoholId: Long): ResponseEntity<*> { - return GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) - } + fun getAlcoholDetail( + @PathVariable alcoholId: Long + ): ResponseEntity<*> = GlobalResponse.ok(alcoholQueryService.findAdminAlcoholDetailById(alcoholId)) @GetMapping("/categories/reference") fun getCategoryReference(): ResponseEntity<*> { @@ -43,20 +33,18 @@ class AdminAlcoholsController( } @PostMapping - fun createAlcohol(@RequestBody @Valid request: AdminAlcoholUpsertRequest): ResponseEntity<*> { - return GlobalResponse.ok(adminAlcoholCommandService.createAlcohol(request)) - } + fun createAlcohol( + @RequestBody @Valid request: AdminAlcoholUpsertRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminAlcoholCommandService.createAlcohol(request)) @PutMapping("/{alcoholId}") fun updateAlcohol( @PathVariable alcoholId: Long, @RequestBody @Valid request: AdminAlcoholUpsertRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminAlcoholCommandService.updateAlcohol(alcoholId, request)) - } + ): ResponseEntity<*> = GlobalResponse.ok(adminAlcoholCommandService.updateAlcohol(alcoholId, request)) @DeleteMapping("/{alcoholId}") - fun deleteAlcohol(@PathVariable alcoholId: Long): ResponseEntity<*> { - return GlobalResponse.ok(adminAlcoholCommandService.deleteAlcohol(alcoholId)) - } + fun deleteAlcohol( + @PathVariable alcoholId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminAlcoholCommandService.deleteAlcohol(alcoholId)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt index 4b975a972..507926bd9 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminCurationController.kt @@ -1,89 +1,64 @@ package app.bottlenote.alcohols.presentation -import app.bottlenote.alcohols.dto.request.AdminCurationAlcoholRequest -import app.bottlenote.alcohols.dto.request.AdminCurationCreateRequest -import app.bottlenote.alcohols.dto.request.AdminCurationDisplayOrderRequest -import app.bottlenote.alcohols.dto.request.AdminCurationSearchRequest -import app.bottlenote.alcohols.dto.request.AdminCurationStatusRequest -import app.bottlenote.alcohols.dto.request.AdminCurationUpdateRequest +import app.bottlenote.alcohols.dto.request.* import app.bottlenote.alcohols.service.AdminCurationService import app.bottlenote.global.data.response.GlobalResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/curations") class AdminCurationController( - private val adminCurationService: AdminCurationService + private val adminCurationService: AdminCurationService ) { + @GetMapping + fun list( + @ModelAttribute request: AdminCurationSearchRequest + ): ResponseEntity = ResponseEntity.ok(adminCurationService.search(request)) - @GetMapping - fun list(@ModelAttribute request: AdminCurationSearchRequest): ResponseEntity { - return ResponseEntity.ok(adminCurationService.search(request)) - } + @GetMapping("/{curationId}") + fun detail( + @PathVariable curationId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.getDetail(curationId)) - @GetMapping("/{curationId}") - fun detail(@PathVariable curationId: Long): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.getDetail(curationId)) - } + @PostMapping + fun create( + @RequestBody @Valid request: AdminCurationCreateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.create(request)) - @PostMapping - fun create(@RequestBody @Valid request: AdminCurationCreateRequest): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.create(request)) - } + @PutMapping("/{curationId}") + fun update( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationUpdateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.update(curationId, request)) - @PutMapping("/{curationId}") - fun update( - @PathVariable curationId: Long, - @RequestBody @Valid request: AdminCurationUpdateRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.update(curationId, request)) - } + @DeleteMapping("/{curationId}") + fun delete( + @PathVariable curationId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.delete(curationId)) - @DeleteMapping("/{curationId}") - fun delete(@PathVariable curationId: Long): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.delete(curationId)) - } + @PatchMapping("/{curationId}/status") + fun updateStatus( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationStatusRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.updateStatus(curationId, request)) - @PatchMapping("/{curationId}/status") - fun updateStatus( - @PathVariable curationId: Long, - @RequestBody @Valid request: AdminCurationStatusRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.updateStatus(curationId, request)) - } + @PatchMapping("/{curationId}/display-order") + fun updateDisplayOrder( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationDisplayOrderRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.updateDisplayOrder(curationId, request)) - @PatchMapping("/{curationId}/display-order") - fun updateDisplayOrder( - @PathVariable curationId: Long, - @RequestBody @Valid request: AdminCurationDisplayOrderRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.updateDisplayOrder(curationId, request)) - } + @PostMapping("/{curationId}/alcohols") + fun addAlcohols( + @PathVariable curationId: Long, + @RequestBody @Valid request: AdminCurationAlcoholRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.addAlcohols(curationId, request)) - @PostMapping("/{curationId}/alcohols") - fun addAlcohols( - @PathVariable curationId: Long, - @RequestBody @Valid request: AdminCurationAlcoholRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.addAlcohols(curationId, request)) - } - - @DeleteMapping("/{curationId}/alcohols/{alcoholId}") - fun removeAlcohol( - @PathVariable curationId: Long, - @PathVariable alcoholId: Long - ): ResponseEntity<*> { - return GlobalResponse.ok(adminCurationService.removeAlcohol(curationId, alcoholId)) - } + @DeleteMapping("/{curationId}/alcohols/{alcoholId}") + fun removeAlcohol( + @PathVariable curationId: Long, + @PathVariable alcoholId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminCurationService.removeAlcohol(curationId, alcoholId)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt index 60dd68818..6bbd30558 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminDistilleryController.kt @@ -2,22 +2,19 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.alcohols.service.AlcoholReferenceService -import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController - @RestController @RequestMapping("/distilleries") class AdminDistilleryController( private val alcoholReferenceService: AlcoholReferenceService ) { - @GetMapping - fun getAllDistilleries(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - return ResponseEntity.ok(alcoholReferenceService.findAllDistilleries(request)) - } + fun getAllDistilleries( + @ModelAttribute request: AdminReferenceSearchRequest + ): ResponseEntity<*> = ResponseEntity.ok(alcoholReferenceService.findAllDistilleries(request)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt index b67ce25c2..fcb355d15 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminRegionController.kt @@ -2,22 +2,19 @@ package app.bottlenote.alcohols.presentation import app.bottlenote.alcohols.dto.request.AdminReferenceSearchRequest import app.bottlenote.alcohols.service.AlcoholReferenceService -import app.bottlenote.global.data.response.GlobalResponse import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.ModelAttribute import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController - @RestController @RequestMapping("/regions") class AdminRegionController( private val alcoholReferenceService: AlcoholReferenceService ) { - @GetMapping - fun getAllRegions(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - return ResponseEntity.ok(alcoholReferenceService.findAllRegionsForAdmin(request)) - } + fun getAllRegions( + @ModelAttribute request: AdminReferenceSearchRequest + ): ResponseEntity<*> = ResponseEntity.ok(alcoholReferenceService.findAllRegionsForAdmin(request)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt index e49d029a4..de1328071 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/alcohols/presentation/AdminTastingTagController.kt @@ -8,16 +8,7 @@ import app.bottlenote.alcohols.service.TastingTagService import app.bottlenote.global.data.response.GlobalResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/tasting-tags") @@ -25,48 +16,41 @@ class AdminTastingTagController( private val alcoholReferenceService: AlcoholReferenceService, private val tastingTagService: TastingTagService ) { - @GetMapping - fun getAllTastingTags(@ModelAttribute request: AdminReferenceSearchRequest): ResponseEntity<*> { - return ResponseEntity.ok(alcoholReferenceService.findAllTastingTags(request)) - } + fun getAllTastingTags( + @ModelAttribute request: AdminReferenceSearchRequest + ): ResponseEntity<*> = ResponseEntity.ok(alcoholReferenceService.findAllTastingTags(request)) @GetMapping("/{tagId}") - fun getTagDetail(@PathVariable tagId: Long): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.getTagDetail(tagId)) - } + fun getTagDetail( + @PathVariable tagId: Long + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.getTagDetail(tagId)) @PostMapping - fun createTag(@RequestBody @Valid request: AdminTastingTagUpsertRequest): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.createTag(request)) - } + fun createTag( + @RequestBody @Valid request: AdminTastingTagUpsertRequest + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.createTag(request)) @PutMapping("/{tagId}") fun updateTag( @PathVariable tagId: Long, @RequestBody @Valid request: AdminTastingTagUpsertRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.updateTag(tagId, request)) - } + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.updateTag(tagId, request)) @DeleteMapping("/{tagId}") - fun deleteTag(@PathVariable tagId: Long): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.deleteTag(tagId)) - } + fun deleteTag( + @PathVariable tagId: Long + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.deleteTag(tagId)) @PostMapping("/{tagId}/alcohols") fun addAlcoholsToTag( @PathVariable tagId: Long, @RequestBody @Valid request: AdminTastingTagAlcoholRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.addAlcoholsToTag(tagId, request.alcoholIds())) - } + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.addAlcoholsToTag(tagId, request.alcoholIds())) @DeleteMapping("/{tagId}/alcohols") fun removeAlcoholsFromTag( @PathVariable tagId: Long, @RequestBody @Valid request: AdminTastingTagAlcoholRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(tastingTagService.removeAlcoholsFromTag(tagId, request.alcoholIds())) - } + ): ResponseEntity<*> = GlobalResponse.ok(tastingTagService.removeAlcoholsFromTag(tagId, request.alcoholIds())) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt index c36efdbb0..427a366bf 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/auth/config/RootAdminProperties.kt @@ -11,7 +11,5 @@ data class RootAdminProperties( /** * 주입받은 인코더를 사용하여 비밀번호를 암호화하여 반환합니다. */ - fun getEncodedPassword(encoder: BCryptPasswordEncoder): String { - return encoder.encode(password) - } + fun getEncodedPassword(encoder: BCryptPasswordEncoder): String = encoder.encode(password) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt index 26ad02b5e..82ffdeeed 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/banner/presentation/AdminBannerController.kt @@ -1,72 +1,52 @@ package app.bottlenote.banner.presentation -import app.bottlenote.banner.dto.request.AdminBannerCreateRequest -import app.bottlenote.banner.dto.request.AdminBannerSearchRequest -import app.bottlenote.banner.dto.request.AdminBannerSortOrderRequest -import app.bottlenote.banner.dto.request.AdminBannerStatusRequest -import app.bottlenote.banner.dto.request.AdminBannerUpdateRequest +import app.bottlenote.banner.dto.request.* import app.bottlenote.banner.service.AdminBannerService import app.bottlenote.global.data.response.GlobalResponse import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.PutMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/banners") class AdminBannerController( - private val adminBannerService: AdminBannerService + private val adminBannerService: AdminBannerService ) { - - @GetMapping - fun list(@ModelAttribute request: AdminBannerSearchRequest): ResponseEntity { - return ResponseEntity.ok(adminBannerService.search(request)) - } - - @GetMapping("/{bannerId}") - fun detail(@PathVariable bannerId: Long): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.getDetail(bannerId)) - } - - @PostMapping - fun create(@RequestBody @Valid request: AdminBannerCreateRequest): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.create(request)) - } - - @PutMapping("/{bannerId}") - fun update( - @PathVariable bannerId: Long, - @RequestBody @Valid request: AdminBannerUpdateRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.update(bannerId, request)) - } - - @DeleteMapping("/{bannerId}") - fun delete(@PathVariable bannerId: Long): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.delete(bannerId)) - } - - @PatchMapping("/{bannerId}/status") - fun updateStatus( - @PathVariable bannerId: Long, - @RequestBody @Valid request: AdminBannerStatusRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.updateStatus(bannerId, request)) - } - - @PatchMapping("/{bannerId}/sort-order") - fun updateSortOrder( - @PathVariable bannerId: Long, - @RequestBody @Valid request: AdminBannerSortOrderRequest - ): ResponseEntity<*> { - return GlobalResponse.ok(adminBannerService.updateSortOrder(bannerId, request)) - } + @GetMapping + fun list( + @ModelAttribute request: AdminBannerSearchRequest + ): ResponseEntity = ResponseEntity.ok(adminBannerService.search(request)) + + @GetMapping("/{bannerId}") + fun detail( + @PathVariable bannerId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.getDetail(bannerId)) + + @PostMapping + fun create( + @RequestBody @Valid request: AdminBannerCreateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.create(request)) + + @PutMapping("/{bannerId}") + fun update( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerUpdateRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.update(bannerId, request)) + + @DeleteMapping("/{bannerId}") + fun delete( + @PathVariable bannerId: Long + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.delete(bannerId)) + + @PatchMapping("/{bannerId}/status") + fun updateStatus( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerStatusRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.updateStatus(bannerId, request)) + + @PatchMapping("/{bannerId}/sort-order") + fun updateSortOrder( + @PathVariable bannerId: Long, + @RequestBody @Valid request: AdminBannerSortOrderRequest + ): ResponseEntity<*> = GlobalResponse.ok(adminBannerService.updateSortOrder(bannerId, request)) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt index 8400d8e19..53abf29a8 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/common/file/presentation/AdminImageUploadController.kt @@ -17,11 +17,14 @@ import org.springframework.web.bind.annotation.RestController class AdminImageUploadController( private val imageUploadService: ImageUploadService ) { - @GetMapping("/presign-url") - fun getPreSignUrl(@ModelAttribute request: ImageUploadRequest): ResponseEntity<*> { - val adminId = SecurityContextUtil.getAdminUserIdByContext() - .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + fun getPreSignUrl( + @ModelAttribute request: ImageUploadRequest + ): ResponseEntity<*> { + val adminId = + SecurityContextUtil + .getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } return GlobalResponse.ok(imageUploadService.getPreSignUrlForAdmin(adminId, request)) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt index 62c354c2d..f182bc814 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/support/help/presentation/AdminHelpController.kt @@ -9,28 +9,25 @@ import app.bottlenote.user.exception.UserException import app.bottlenote.user.exception.UserExceptionCode import jakarta.validation.Valid import org.springframework.http.ResponseEntity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.ModelAttribute -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/helps") class AdminHelpController( private val adminHelpService: AdminHelpService ) { - @GetMapping - fun getHelpList(@ModelAttribute request: AdminHelpPageableRequest): ResponseEntity<*> { + fun getHelpList( + @ModelAttribute request: AdminHelpPageableRequest + ): ResponseEntity<*> { val response = adminHelpService.getHelpList(request) return GlobalResponse.ok(response) } @GetMapping("/{helpId}") - fun getHelpDetail(@PathVariable helpId: Long): ResponseEntity<*> { + fun getHelpDetail( + @PathVariable helpId: Long + ): ResponseEntity<*> { val response = adminHelpService.getHelpDetail(helpId) return GlobalResponse.ok(response) } @@ -40,8 +37,10 @@ class AdminHelpController( @PathVariable helpId: Long, @RequestBody @Valid request: AdminHelpAnswerRequest ): ResponseEntity<*> { - val adminId = SecurityContextUtil.getAdminUserIdByContext() - .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + val adminId = + SecurityContextUtil + .getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } val response = adminHelpService.answerHelp(helpId, adminId, request) return GlobalResponse.ok(response) } diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/common/ApplicationReadyEvent.kt b/bottlenote-admin-api/src/main/kotlin/app/global/common/ApplicationReadyEvent.kt index d9abbb082..600291bab 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/global/common/ApplicationReadyEvent.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/global/common/ApplicationReadyEvent.kt @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component @Component class ApplicationReadyEvent( - private val info: AppInfoConfig, + private val info: AppInfoConfig ) { companion object { private val log = LoggerFactory.getLogger(ApplicationReadyEvent::class.java) @@ -18,7 +18,8 @@ class ApplicationReadyEvent( @Order(Int.MAX_VALUE - 1) @EventListener(ApplicationReadyEvent::class) fun displayServiceBanner() { - val banner = """ + val banner = + """ ▗▄▄▖ ▗▄▖▗▄▄▄▖▗▄▄▄▖▗▖ ▗▄▄▄▖ ▗▖ ▗▖ ▗▄▖▗▄▄▄▖▗▄▄▄▖ ▐▌ ▐▌▐▌ ▐▌ █ █ ▐▌ ▐▌ ▐▛▚▖▐▌▐▌ ▐▌ █ ▐▌ ▐▛▀▚▖▐▌ ▐▌ █ █ ▐▌ ▐▛▀▀▘ ▐▌ ▝▜▌▐▌ ▐▌ █ ▐▛▀▀▘ @@ -35,7 +36,7 @@ class ApplicationReadyEvent( - Git Commit : ${info.gitCommit} - Build Time : ${info.gitBuildTime} ================================================================================ - """.trimIndent() + """.trimIndent() println(banner) } } diff --git a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt index 105f0c001..dba37f817 100644 --- a/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt +++ b/bottlenote-admin-api/src/main/kotlin/app/global/security/SecurityConfig.kt @@ -20,28 +20,27 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource class SecurityConfig( private val adminJwtAuthenticationManager: AdminJwtAuthenticationManager ) { - @Bean - fun filterChain(http: HttpSecurity): SecurityFilterChain { - return http - .csrf { it.disable() } - .cors { it.configurationSource(corsConfigurationSource()) } - .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } - .formLogin { it.disable() } - .httpBasic { it.disable() } - .authorizeHttpRequests { auth -> - auth - .requestMatchers(*MaliciousPathPattern.getAllPatterns()).denyAll() - .requestMatchers("/auth/login", "/auth/refresh").permitAll() - .requestMatchers("/actuator/**").permitAll() - .anyRequest().authenticated() - } - .addFilterBefore( - AdminJwtAuthenticationFilter(adminJwtAuthenticationManager), - UsernamePasswordAuthenticationFilter::class.java - ) - .build() - } + fun filterChain(http: HttpSecurity): SecurityFilterChain = http + .csrf { it.disable() } + .cors { it.configurationSource(corsConfigurationSource()) } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .formLogin { it.disable() } + .httpBasic { it.disable() } + .authorizeHttpRequests { auth -> + auth + .requestMatchers(*MaliciousPathPattern.getAllPatterns()) + .denyAll() + .requestMatchers("/auth/login", "/auth/refresh") + .permitAll() + .requestMatchers("/actuator/**") + .permitAll() + .anyRequest() + .authenticated() + }.addFilterBefore( + AdminJwtAuthenticationFilter(adminJwtAuthenticationManager), + UsernamePasswordAuthenticationFilter::class.java + ).build() @Bean fun corsConfigurationSource(): CorsConfigurationSource { @@ -57,7 +56,5 @@ class SecurityConfig( } @Bean - fun bCryptPasswordEncoder(): BCryptPasswordEncoder { - return BCryptPasswordEncoder() - } + fun bCryptPasswordEncoder(): BCryptPasswordEncoder = BCryptPasswordEncoder() } diff --git a/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt index 5d2b74386..25850c98c 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/ApplicationContextStartupIntegrationTest.kt @@ -11,7 +11,6 @@ import org.testcontainers.containers.MySQLContainer @Tag("admin_integration") @DisplayName("[integration] Admin API 컨텍스트 로드 테스트") class ApplicationContextStartupIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var mysqlContainer: MySQLContainer diff --git a/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt b/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt index 5c59abfa6..fad388a2a 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/IntegrationTestSupport.kt @@ -28,7 +28,6 @@ import org.springframework.test.web.servlet.assertj.MvcTestResult @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) abstract class IntegrationTestSupport { - companion object { @JvmStatic protected val log: Logger = LogManager.getLogger(IntegrationTestSupport::class.java) @@ -57,16 +56,12 @@ abstract class IntegrationTestSupport { /** * AdminUser에 대한 토큰 생성 */ - protected fun createToken(admin: AdminUser): TokenItem { - return jwtTokenProvider.generateAdminToken(admin.email, admin.roles, admin.id) - } + protected fun createToken(admin: AdminUser): TokenItem = jwtTokenProvider.generateAdminToken(admin.email, admin.roles, admin.id) /** * AdminUser에 대한 액세스 토큰 문자열 반환 */ - protected fun getAccessToken(admin: AdminUser): String { - return createToken(admin).accessToken() - } + protected fun getAccessToken(admin: AdminUser): String = createToken(admin).accessToken() /** * MvcTestResult에서 GlobalResponse 파싱 @@ -79,7 +74,10 @@ abstract class IntegrationTestSupport { /** * MvcTestResult에서 data 필드를 지정 타입으로 변환 */ - protected fun extractData(result: MvcTestResult, dataType: Class): T { + protected fun extractData( + result: MvcTestResult, + dataType: Class + ): T { val response = parseResponse(result) return mapper.convertValue(response.data, dataType) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt index 3f6f74465..f9c593058 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminAlcoholsControllerDocsTest.kt @@ -11,10 +11,10 @@ import app.bottlenote.global.dto.response.AdminResultResponse import app.bottlenote.global.service.cursor.SortOrder import app.helper.alcohols.AlcoholsHelper import com.fasterxml.jackson.databind.ObjectMapper +import org.apache.commons.lang3.tuple.Pair import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test -import org.apache.commons.lang3.tuple.Pair import org.mockito.BDDMockito.given import org.mockito.Mockito.any import org.mockito.Mockito.anyLong diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt index eb3f79728..3cfa10f2b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/alcohols/AdminTastingTagControllerDocsTest.kt @@ -122,10 +122,22 @@ class AdminTastingTagControllerDocsTest { fun getTagDetail() { // given val childNode = TastingTagNodeItem.of( - 2L, "바닐라 크림", "Vanilla Cream", null, "바닐라 크림 향", null, emptyList() + 2L, + "바닐라 크림", + "Vanilla Cream", + null, + "바닐라 크림 향", + null, + emptyList() ) val tagNode = TastingTagNodeItem.of( - 1L, "바닐라", "Vanilla", "base64icon", "바닐라 향", null, listOf(childNode) + 1L, + "바닐라", + "Vanilla", + "base64icon", + "바닐라 향", + null, + listOf(childNode) ) val alcoholItem = AdminAlcoholItem( 1L, "글렌피딕 12년", "Glenfiddich 12", "싱글몰트", "Single Malt", diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt index a3d5c2062..b62355e80 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/banner/AdminBannerControllerDocsTest.kt @@ -33,413 +33,413 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @WebMvcTest( - controllers = [AdminBannerController::class], - excludeAutoConfiguration = [SecurityAutoConfiguration::class] + controllers = [AdminBannerController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs @DisplayName("Admin Banner 컨트롤러 RestDocs 테스트") class AdminBannerControllerDocsTest { - @Autowired - private lateinit var mvc: MockMvcTester - - @Autowired - private lateinit var mapper: ObjectMapper - - @MockitoBean - private lateinit var adminBannerService: AdminBannerService - - @Nested - @DisplayName("배너 목록 조회") - inner class ListBanners { - - @Test - @DisplayName("배너 목록을 조회할 수 있다") - fun listBanners() { - // given - val items = BannerHelper.createAdminBannerListResponses(3) - val page = PageImpl(items) - val response = GlobalResponse.fromPage(page) - - given(adminBannerService.search(any(AdminBannerSearchRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.get().uri("/banners?keyword=&isActive=true&bannerType=CURATION&page=0&size=20") - ) - .hasStatusOk() - .apply( - document( - "admin/banners/list", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - queryParameters( - parameterWithName("keyword").description("검색어 (배너명)").optional(), - parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), - parameterWithName("bannerType").description("배너 유형 필터 (CURATION/AD/SURVEY/PARTNERSHIP/ETC)").optional(), - parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("배너 목록"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("배너 ID"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배너명"), - fieldWithPath("data[].mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), - fieldWithPath("data[].bannerType").type(JsonFieldType.STRING).description("배너 유형"), - fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), - fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data[].startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), - fieldWithPath("data[].endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), - fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 상세 조회") - inner class GetBannerDetail { - - @Test - @DisplayName("배너 상세 정보를 조회할 수 있다") - fun getBannerDetail() { - // given - val response = BannerHelper.createAdminBannerDetailResponse() - - given(adminBannerService.getDetail(anyLong())).willReturn(response) - - // when & then - assertThat(mvc.get().uri("/banners/{bannerId}", 1L)) - .hasStatusOk() - .apply( - document( - "admin/banners/detail", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("bannerId").description("배너 ID") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("배너 상세 정보"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("배너 ID"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("배너명"), - fieldWithPath("data.nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), - fieldWithPath("data.descriptionA").type(JsonFieldType.STRING).description("배너 설명A").optional(), - fieldWithPath("data.descriptionB").type(JsonFieldType.STRING).description("배너 설명B").optional(), - fieldWithPath("data.descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), - fieldWithPath("data.imageUrl").type(JsonFieldType.STRING).description("이미지 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), - fieldWithPath("data.textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), - fieldWithPath("data.isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), - fieldWithPath("data.targetUrl").type(JsonFieldType.VARIES).description("이동 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), - fieldWithPath("data.mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), - fieldWithPath("data.bannerType").type(JsonFieldType.STRING).description("배너 유형"), - fieldWithPath("data.sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), - fieldWithPath("data.startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), - fieldWithPath("data.endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), - fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 생성") - inner class CreateBanner { - - @Test - @DisplayName("배너를 생성할 수 있다") - fun createBanner() { - // given - val request = BannerHelper.createBannerCreateRequest() - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_CREATED, 1L) - - given(adminBannerService.create(any(AdminBannerCreateRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.post().uri("/banners") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/banners/create", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - requestFields( - fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), - fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), - fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), - fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), - fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), - fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), - fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등, 기본값: RT)").optional(), - fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부 (기본값: false)").optional(), - fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), - fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 기본값: IMAGE). 프론트엔드에서 img/video 태그 분기용").optional(), - fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), - fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 기본값: 0)").optional(), - fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), - fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 배너 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 수정") - inner class UpdateBanner { - - @Test - @DisplayName("배너를 수정할 수 있다") - fun updateBanner() { - // given - val request = BannerHelper.createBannerUpdateRequest() - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_UPDATED, 1L) - - given(adminBannerService.update(anyLong(), any(AdminBannerUpdateRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.put().uri("/banners/{bannerId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/banners/update", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("bannerId").description("배너 ID") - ), - requestFields( - fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), - fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), - fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), - fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), - fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), - fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), - fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), - fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), - fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), - fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 미입력 시 기존 값 유지). 프론트엔드에서 img/video 태그 분기용").optional(), - fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), - fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)"), - fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), - fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), - fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 배너 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 삭제") - inner class DeleteBanner { - - @Test - @DisplayName("배너를 삭제할 수 있다") - fun deleteBanner() { - // given - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_DELETED, 1L) - - given(adminBannerService.delete(anyLong())).willReturn(response) - - // when & then - assertThat(mvc.delete().uri("/banners/{bannerId}", 1L)) - .hasStatusOk() - .apply( - document( - "admin/banners/delete", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("bannerId").description("배너 ID") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 배너 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 활성화 상태 변경") - inner class UpdateBannerStatus { - - @Test - @DisplayName("배너 활성화 상태를 변경할 수 있다") - fun updateStatus() { - // given - val request = BannerHelper.createBannerStatusRequest(isActive = false) - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_STATUS_UPDATED, 1L) - - given(adminBannerService.updateStatus(anyLong(), any(AdminBannerStatusRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.patch().uri("/banners/{bannerId}/status", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/banners/update-status", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("bannerId").description("배너 ID") - ), - requestFields( - fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("배너 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("배너 정렬 순서 변경") - inner class UpdateBannerSortOrder { - - @Test - @DisplayName("배너 정렬 순서를 변경할 수 있다") - fun updateSortOrder() { - // given - val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_SORT_ORDER_UPDATED, 1L) - - given(adminBannerService.updateSortOrder(anyLong(), any(AdminBannerSortOrderRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.patch().uri("/banners/{bannerId}/sort-order", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/banners/update-sort-order", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("bannerId").description("배너 ID") - ), - requestFields( - fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("배너 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var mapper: ObjectMapper + + @MockitoBean + private lateinit var adminBannerService: AdminBannerService + + @Nested + @DisplayName("배너 목록 조회") + inner class ListBanners { + + @Test + @DisplayName("배너 목록을 조회할 수 있다") + fun listBanners() { + // given + val items = BannerHelper.createAdminBannerListResponses(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(adminBannerService.search(any(AdminBannerSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/banners?keyword=&isActive=true&bannerType=CURATION&page=0&size=20") + ) + .hasStatusOk() + .apply( + document( + "admin/banners/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (배너명)").optional(), + parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), + parameterWithName("bannerType").description("배너 유형 필터 (CURATION/AD/SURVEY/PARTNERSHIP/ETC)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("배너 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("배너명"), + fieldWithPath("data[].mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), + fieldWithPath("data[].bannerType").type(JsonFieldType.STRING).description("배너 유형"), + fieldWithPath("data[].sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), + fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data[].startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("data[].endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 상세 조회") + inner class GetBannerDetail { + + @Test + @DisplayName("배너 상세 정보를 조회할 수 있다") + fun getBannerDetail() { + // given + val response = BannerHelper.createAdminBannerDetailResponse() + + given(adminBannerService.getDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/banners/{bannerId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/banners/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("배너 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("배너명"), + fieldWithPath("data.nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), + fieldWithPath("data.descriptionA").type(JsonFieldType.STRING).description("배너 설명A").optional(), + fieldWithPath("data.descriptionB").type(JsonFieldType.STRING).description("배너 설명B").optional(), + fieldWithPath("data.descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), + fieldWithPath("data.imageUrl").type(JsonFieldType.STRING).description("이미지 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("data.textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), + fieldWithPath("data.isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), + fieldWithPath("data.targetUrl").type(JsonFieldType.VARIES).description("이동 URL. [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("data.mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE, VIDEO). 프론트엔드에서 img/video 태그 분기용"), + fieldWithPath("data.bannerType").type(JsonFieldType.STRING).description("배너 유형"), + fieldWithPath("data.sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서"), + fieldWithPath("data.startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("data.endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 생성") + inner class CreateBanner { + + @Test + @DisplayName("배너를 생성할 수 있다") + fun createBanner() { + // given + val request = BannerHelper.createBannerCreateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_CREATED, 1L) + + given(adminBannerService.create(any(AdminBannerCreateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/banners") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), + fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), + fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), + fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), + fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX, 기본값: #ffffff)").optional(), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등, 기본값: RT)").optional(), + fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부 (기본값: false)").optional(), + fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 기본값: IMAGE). 프론트엔드에서 img/video 태그 분기용").optional(), + fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 기본값: 0)").optional(), + fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 배너 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 수정") + inner class UpdateBanner { + + @Test + @DisplayName("배너를 수정할 수 있다") + fun updateBanner() { + // given + val request = BannerHelper.createBannerUpdateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_UPDATED, 1L) + + given(adminBannerService.update(anyLong(), any(AdminBannerUpdateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/banners/{bannerId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("배너명 (필수)"), + fieldWithPath("nameFontColor").type(JsonFieldType.STRING).description("배너명 폰트 색상 (HEX)"), + fieldWithPath("descriptionA").type(JsonFieldType.STRING).description("배너 설명A (최대 50자)").optional(), + fieldWithPath("descriptionB").type(JsonFieldType.STRING).description("배너 설명B (최대 50자)").optional(), + fieldWithPath("descriptionFontColor").type(JsonFieldType.STRING).description("설명 폰트 색상 (HEX)"), + fieldWithPath("imageUrl").type(JsonFieldType.STRING).description("이미지 URL (필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다"), + fieldWithPath("textPosition").type(JsonFieldType.STRING).description("텍스트 위치 (RT/CENTER/LB 등)"), + fieldWithPath("isExternalUrl").type(JsonFieldType.BOOLEAN).description("외부 URL 여부"), + fieldWithPath("targetUrl").type(JsonFieldType.VARIES).description("이동 URL (isExternalUrl=true 시 필수). [주의] URL 형식 검증을 수행하지 않으므로 클라이언트에서 유효한 URL을 전달해야 합니다").optional(), + fieldWithPath("mediaType").type(JsonFieldType.STRING).description("미디어 유형 (IMAGE/VIDEO, 미입력 시 기존 값 유지). 프론트엔드에서 img/video 태그 분기용").optional(), + fieldWithPath("bannerType").type(JsonFieldType.STRING).description("배너 유형 (필수: CURATION/AD/SURVEY/PARTNERSHIP/ETC)"), + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)"), + fieldWithPath("startDate").type(JsonFieldType.VARIES).description("시작일시").optional(), + fieldWithPath("endDate").type(JsonFieldType.VARIES).description("종료일시").optional(), + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 배너 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 삭제") + inner class DeleteBanner { + + @Test + @DisplayName("배너를 삭제할 수 있다") + fun deleteBanner() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_DELETED, 1L) + + given(adminBannerService.delete(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/banners/{bannerId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/banners/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 배너 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 활성화 상태 변경") + inner class UpdateBannerStatus { + + @Test + @DisplayName("배너 활성화 상태를 변경할 수 있다") + fun updateStatus() { + // given + val request = BannerHelper.createBannerStatusRequest(isActive = false) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_STATUS_UPDATED, 1L) + + given(adminBannerService.updateStatus(anyLong(), any(AdminBannerStatusRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/banners/{bannerId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update-status", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("배너 정렬 순서 변경") + inner class UpdateBannerSortOrder { + + @Test + @DisplayName("배너 정렬 순서를 변경할 수 있다") + fun updateSortOrder() { + // given + val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.BANNER_SORT_ORDER_UPDATED, 1L) + + given(adminBannerService.updateSortOrder(anyLong(), any(AdminBannerSortOrderRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/banners/{bannerId}/sort-order", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/banners/update-sort-order", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("bannerId").description("배너 ID") + ), + requestFields( + fieldWithPath("sortOrder").type(JsonFieldType.NUMBER).description("정렬 순서 (0 이상, 필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("배너 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt index 85e56874f..6bcc14c5d 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/curation/AdminCurationControllerDocsTest.kt @@ -29,484 +29,484 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.assertj.MockMvcTester @WebMvcTest( - controllers = [AdminCurationController::class], - excludeAutoConfiguration = [SecurityAutoConfiguration::class] + controllers = [AdminCurationController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] ) @AutoConfigureRestDocs @DisplayName("Admin Curation 컨트롤러 RestDocs 테스트") class AdminCurationControllerDocsTest { - @Autowired - private lateinit var mvc: MockMvcTester - - @Autowired - private lateinit var mapper: ObjectMapper - - @MockitoBean - private lateinit var adminCurationService: AdminCurationService - - @Nested - @DisplayName("큐레이션 목록 조회") - inner class ListCurations { - - @Test - @DisplayName("큐레이션 목록을 조회할 수 있다") - fun listCurations() { - // given - val items = CurationHelper.createAdminCurationListResponses(3) - val page = PageImpl(items) - val response = GlobalResponse.fromPage(page) - - given(adminCurationService.search(any(AdminCurationSearchRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.get().uri("/curations?keyword=&isActive=true&page=0&size=20") - ) - .hasStatusOk() - .apply( - document( - "admin/curations/list", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - queryParameters( - parameterWithName("keyword").description("검색어 (큐레이션명)").optional(), - parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), - parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), - parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("큐레이션 목록"), - fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data[].name").type(JsonFieldType.STRING).description("큐레이션명"), - fieldWithPath("data[].alcoholCount").type(JsonFieldType.NUMBER).description("포함된 위스키 수"), - fieldWithPath("data[].displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), - fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), - fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), - fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), - fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), - fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 상세 조회") - inner class GetCurationDetail { - - @Test - @DisplayName("큐레이션 상세 정보를 조회할 수 있다") - fun getCurationDetail() { - // given - val response = CurationHelper.createAdminCurationDetailResponse() - - given(adminCurationService.getDetail(anyLong())).willReturn(response) - - // when & then - assertThat(mvc.get().uri("/curations/{curationId}", 1L)) - .hasStatusOk() - .apply( - document( - "admin/curations/detail", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("큐레이션 상세 정보"), - fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.name").type(JsonFieldType.STRING).description("큐레이션명"), - fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), - fieldWithPath("data.coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), - fieldWithPath("data.displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), - fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), - fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("포함된 위스키 목록"), - fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), - fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("한글명"), - fieldWithPath("data.alcohols[].engName").type(JsonFieldType.STRING).description("영문명"), - fieldWithPath("data.alcohols[].korCategoryName").type(JsonFieldType.STRING).description("한글 카테고리명"), - fieldWithPath("data.alcohols[].engCategoryName").type(JsonFieldType.STRING).description("영문 카테고리명"), - fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), - fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("data.alcohols[].deletedAt").type(JsonFieldType.STRING).description("삭제일시").optional(), - fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), - fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 생성") - inner class CreateCuration { - - @Test - @DisplayName("큐레이션을 생성할 수 있다") - fun createCuration() { - // given - val request = CurationHelper.createCurationCreateRequest() - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_CREATED, 1L) - - given(adminCurationService.create(any(AdminCurationCreateRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.post().uri("/curations") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/create", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - requestFields( - fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), - fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), - fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), - fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (기본값: 0)").optional(), - fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록").optional() - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 수정") - inner class UpdateCuration { - - @Test - @DisplayName("큐레이션을 수정할 수 있다") - fun updateCuration() { - // given - val request = CurationHelper.createCurationUpdateRequest() - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_UPDATED, 1L) - - given(adminCurationService.update(anyLong(), any(AdminCurationUpdateRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.put().uri("/curations/{curationId}", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/update", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - requestFields( - fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), - fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), - fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), - fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (필수)"), - fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)"), - fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 삭제") - inner class DeleteCuration { - - @Test - @DisplayName("큐레이션을 삭제할 수 있다") - fun deleteCuration() { - // given - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DELETED, 1L) - - given(adminCurationService.delete(anyLong())).willReturn(response) - - // when & then - assertThat(mvc.delete().uri("/curations/{curationId}", 1L)) - .hasStatusOk() - .apply( - document( - "admin/curations/delete", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 활성화 상태 변경") - inner class UpdateCurationStatus { - - @Test - @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") - fun updateStatus() { - // given - val request = CurationHelper.createCurationStatusRequest(isActive = false) - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_STATUS_UPDATED, 1L) - - given(adminCurationService.updateStatus(anyLong(), any(AdminCurationStatusRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.patch().uri("/curations/{curationId}/status", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/update-status", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - requestFields( - fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 노출 순서 변경") - inner class UpdateCurationDisplayOrder { - - @Test - @DisplayName("큐레이션 노출 순서를 변경할 수 있다") - fun updateDisplayOrder() { - // given - val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DISPLAY_ORDER_UPDATED, 1L) - - given(adminCurationService.updateDisplayOrder(anyLong(), any(AdminCurationDisplayOrderRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.patch().uri("/curations/{curationId}/display-order", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/update-display-order", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - requestFields( - fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (0 이상, 필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } - - @Nested - @DisplayName("큐레이션 위스키 관리") - inner class ManageCurationAlcohols { - - @Test - @DisplayName("큐레이션에 위스키를 추가할 수 있다") - fun addAlcohols() { - // given - val request = CurationHelper.createCurationAlcoholRequest(alcoholIds = setOf(1L, 2L, 3L)) - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_ADDED, 1L) - - given(adminCurationService.addAlcohols(anyLong(), any(AdminCurationAlcoholRequest::class.java))) - .willReturn(response) - - // when & then - assertThat( - mvc.post().uri("/curations/{curationId}/alcohols", 1L) - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/add-alcohols", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID") - ), - requestFields( - fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("추가할 위스키 ID 목록 (필수)") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - - @Test - @DisplayName("큐레이션에서 위스키를 제거할 수 있다") - fun removeAlcohol() { - // given - val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_REMOVED, 1L) - - given(adminCurationService.removeAlcohol(anyLong(), anyLong())).willReturn(response) - - // when & then - assertThat( - mvc.delete().uri("/curations/{curationId}/alcohols/{alcoholId}", 1L, 5L) - ) - .hasStatusOk() - .apply( - document( - "admin/curations/remove-alcohol", - preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()), - pathParameters( - parameterWithName("curationId").description("큐레이션 ID"), - parameterWithName("alcoholId").description("제거할 위스키 ID") - ), - responseFields( - fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), - fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), - fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), - fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), - fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), - fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), - fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), - fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), - fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), - fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() - ) - ) - ) - } - } + @Autowired + private lateinit var mvc: MockMvcTester + + @Autowired + private lateinit var mapper: ObjectMapper + + @MockitoBean + private lateinit var adminCurationService: AdminCurationService + + @Nested + @DisplayName("큐레이션 목록 조회") + inner class ListCurations { + + @Test + @DisplayName("큐레이션 목록을 조회할 수 있다") + fun listCurations() { + // given + val items = CurationHelper.createAdminCurationListResponses(3) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(adminCurationService.search(any(AdminCurationSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/curations?keyword=&isActive=true&page=0&size=20") + ) + .hasStatusOk() + .apply( + document( + "admin/curations/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색어 (큐레이션명)").optional(), + parameterWithName("isActive").description("활성화 상태 필터 (true/false/null)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("큐레이션 목록"), + fieldWithPath("data[].id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data[].name").type(JsonFieldType.STRING).description("큐레이션명"), + fieldWithPath("data[].alcoholCount").type(JsonFieldType.NUMBER).description("포함된 위스키 수"), + fieldWithPath("data[].displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), + fieldWithPath("data[].isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 상세 조회") + inner class GetCurationDetail { + + @Test + @DisplayName("큐레이션 상세 정보를 조회할 수 있다") + fun getCurationDetail() { + // given + val response = CurationHelper.createAdminCurationDetailResponse() + + given(adminCurationService.getDetail(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.get().uri("/curations/{curationId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/curations/detail", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("큐레이션 상세 정보"), + fieldWithPath("data.id").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.name").type(JsonFieldType.STRING).description("큐레이션명"), + fieldWithPath("data.description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("data.coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("data.displayOrder").type(JsonFieldType.NUMBER).description("노출 순서"), + fieldWithPath("data.isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태"), + fieldWithPath("data.alcohols").type(JsonFieldType.ARRAY).description("포함된 위스키 목록"), + fieldWithPath("data.alcohols[].alcoholId").type(JsonFieldType.NUMBER).description("위스키 ID"), + fieldWithPath("data.alcohols[].korName").type(JsonFieldType.STRING).description("한글명"), + fieldWithPath("data.alcohols[].engName").type(JsonFieldType.STRING).description("영문명"), + fieldWithPath("data.alcohols[].korCategoryName").type(JsonFieldType.STRING).description("한글 카테고리명"), + fieldWithPath("data.alcohols[].engCategoryName").type(JsonFieldType.STRING).description("영문 카테고리명"), + fieldWithPath("data.alcohols[].imageUrl").type(JsonFieldType.STRING).description("이미지 URL"), + fieldWithPath("data.alcohols[].createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.alcohols[].modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("data.alcohols[].deletedAt").type(JsonFieldType.STRING).description("삭제일시").optional(), + fieldWithPath("data.createdAt").type(JsonFieldType.STRING).description("생성일시"), + fieldWithPath("data.modifiedAt").type(JsonFieldType.STRING).description("수정일시"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 생성") + inner class CreateCuration { + + @Test + @DisplayName("큐레이션을 생성할 수 있다") + fun createCuration() { + // given + val request = CurationHelper.createCurationCreateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_CREATED, 1L) + + given(adminCurationService.create(any(AdminCurationCreateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/curations") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/create", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (기본값: 0)").optional(), + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("생성된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 수정") + inner class UpdateCuration { + + @Test + @DisplayName("큐레이션을 수정할 수 있다") + fun updateCuration() { + // given + val request = CurationHelper.createCurationUpdateRequest() + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_UPDATED, 1L) + + given(adminCurationService.update(anyLong(), any(AdminCurationUpdateRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.put().uri("/curations/{curationId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("name").type(JsonFieldType.STRING).description("큐레이션명 (필수)"), + fieldWithPath("description").type(JsonFieldType.STRING).description("설명").optional(), + fieldWithPath("coverImageUrl").type(JsonFieldType.STRING).description("커버 이미지 URL").optional(), + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (필수)"), + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)"), + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("포함할 위스키 ID 목록") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("수정된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 삭제") + inner class DeleteCuration { + + @Test + @DisplayName("큐레이션을 삭제할 수 있다") + fun deleteCuration() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DELETED, 1L) + + given(adminCurationService.delete(anyLong())).willReturn(response) + + // when & then + assertThat(mvc.delete().uri("/curations/{curationId}", 1L)) + .hasStatusOk() + .apply( + document( + "admin/curations/delete", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("삭제된 큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 활성화 상태 변경") + inner class UpdateCurationStatus { + + @Test + @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") + fun updateStatus() { + // given + val request = CurationHelper.createCurationStatusRequest(isActive = false) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_STATUS_UPDATED, 1L) + + given(adminCurationService.updateStatus(anyLong(), any(AdminCurationStatusRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/curations/{curationId}/status", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update-status", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("isActive").type(JsonFieldType.BOOLEAN).description("활성화 상태 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 노출 순서 변경") + inner class UpdateCurationDisplayOrder { + + @Test + @DisplayName("큐레이션 노출 순서를 변경할 수 있다") + fun updateDisplayOrder() { + // given + val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_DISPLAY_ORDER_UPDATED, 1L) + + given(adminCurationService.updateDisplayOrder(anyLong(), any(AdminCurationDisplayOrderRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.patch().uri("/curations/{curationId}/display-order", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/update-display-order", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("displayOrder").type(JsonFieldType.NUMBER).description("노출 순서 (0 이상, 필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } + + @Nested + @DisplayName("큐레이션 위스키 관리") + inner class ManageCurationAlcohols { + + @Test + @DisplayName("큐레이션에 위스키를 추가할 수 있다") + fun addAlcohols() { + // given + val request = CurationHelper.createCurationAlcoholRequest(alcoholIds = setOf(1L, 2L, 3L)) + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_ADDED, 1L) + + given(adminCurationService.addAlcohols(anyLong(), any(AdminCurationAlcoholRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.post().uri("/curations/{curationId}/alcohols", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/add-alcohols", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID") + ), + requestFields( + fieldWithPath("alcoholIds").type(JsonFieldType.ARRAY).description("추가할 위스키 ID 목록 (필수)") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + + @Test + @DisplayName("큐레이션에서 위스키를 제거할 수 있다") + fun removeAlcohol() { + // given + val response = AdminResultResponse.of(AdminResultResponse.ResultCode.CURATION_ALCOHOL_REMOVED, 1L) + + given(adminCurationService.removeAlcohol(anyLong(), anyLong())).willReturn(response) + + // when & then + assertThat( + mvc.delete().uri("/curations/{curationId}/alcohols/{alcoholId}", 1L, 5L) + ) + .hasStatusOk() + .apply( + document( + "admin/curations/remove-alcohol", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + pathParameters( + parameterWithName("curationId").description("큐레이션 ID"), + parameterWithName("alcoholId").description("제거할 위스키 ID") + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data").type(JsonFieldType.OBJECT).description("결과 정보"), + fieldWithPath("data.code").type(JsonFieldType.STRING).description("결과 코드"), + fieldWithPath("data.message").type(JsonFieldType.STRING).description("결과 메시지"), + fieldWithPath("data.targetId").type(JsonFieldType.NUMBER).description("큐레이션 ID"), + fieldWithPath("data.responseAt").type(JsonFieldType.STRING).description("응답 시간"), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt index 40ff99a5a..9f85a2c4e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/alcohols/AlcoholsHelper.kt @@ -1,19 +1,18 @@ package app.helper.alcohols +import app.bottlenote.alcohols.constant.AlcoholCategoryGroup +import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse import app.bottlenote.alcohols.dto.response.AdminAlcoholDetailResponse.TastingTagInfo import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminDistilleryItem import app.bottlenote.alcohols.dto.response.AdminRegionItem import app.bottlenote.alcohols.dto.response.TastingTagNodeItem -import app.bottlenote.alcohols.constant.AlcoholCategoryGroup -import app.bottlenote.alcohols.constant.AlcoholType import app.bottlenote.global.data.response.GlobalResponse import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime object AlcoholsHelper { - /** 1x1 투명 PNG 이미지 (테스트용) */ const val VALID_BASE64_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" @@ -28,19 +27,26 @@ object AlcoholsHelper { modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0), deletedAt: LocalDateTime? = null ): AdminAlcoholItem = AdminAlcoholItem( - id, korName, engName, korCategoryName, engCategoryName, imageUrl, createdAt, modifiedAt, deletedAt + id, + korName, + engName, + korCategoryName, + engCategoryName, + imageUrl, + createdAt, + modifiedAt, + deletedAt ) - fun createAdminAlcoholItems(count: Int = 2): List = - (1..count).map { i -> - createAdminAlcoholItem( - id = i.toLong(), - korName = "테스트 위스키 $i", - engName = "Test Whisky $i", - createdAt = LocalDateTime.of(2024, i, 1, 0, 0), - modifiedAt = LocalDateTime.of(2024, i + 5, 1, 0, 0) - ) - } + fun createAdminAlcoholItems(count: Int = 2): List = (1..count).map { i -> + createAdminAlcoholItem( + id = i.toLong(), + korName = "테스트 위스키 $i", + engName = "Test Whisky $i", + createdAt = LocalDateTime.of(2024, i, 1, 0, 0), + modifiedAt = LocalDateTime.of(2024, i + 5, 1, 0, 0) + ) + } fun createPageResponse( items: List, @@ -51,7 +57,8 @@ object AlcoholsHelper { val totalPages = if (totalElements == 0L) 0 else ((totalElements - 1) / size + 1).toInt() val hasNext = page < totalPages - 1 - return GlobalResponse.builder() + return GlobalResponse + .builder() .success(true) .code(200) .data(items) @@ -68,8 +75,7 @@ object AlcoholsHelper { "serverResponseTime" to LocalDateTime.now().toString(), "serverPathVersion" to "v1" ) - ) - .build() + ).build() } fun createAdminAlcoholDetailResponse( @@ -117,61 +123,63 @@ object AlcoholsHelper { parent: TastingTagNodeItem? = null, children: List? = null ): TastingTagNodeItem = TastingTagNodeItem.of( - id, korName, engName, icon, description, parent, children + id, + korName, + engName, + icon, + description, + parent, + children ) - fun createTastingTagNodeItems(count: Int = 3): List = - (1..count).map { i -> - TastingTagNodeItem.forList( - i.toLong(), - "태그$i", - "Tag$i", - "icon$i.png", - "테이스팅 태그 설명 $i" - ) - } + fun createTastingTagNodeItems(count: Int = 3): List = (1..count).map { i -> + TastingTagNodeItem.forList( + i.toLong(), + "태그$i", + "Tag$i", + "icon$i.png", + "테이스팅 태그 설명 $i" + ) + } - fun createAdminRegionItems(count: Int = 3): List = - (1..count).map { i -> - AdminRegionItem( - i.toLong(), - listOf("스코틀랜드", "아일랜드", "일본")[i - 1], - listOf("Scotland", "Ireland", "Japan")[i - 1], - listOf("유럽", "유럽", "아시아")[i - 1], - "지역 설명 $i", - LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0), - null - ) - } + fun createAdminRegionItems(count: Int = 3): List = (1..count).map { i -> + AdminRegionItem( + i.toLong(), + listOf("스코틀랜드", "아일랜드", "일본")[i - 1], + listOf("Scotland", "Ireland", "Japan")[i - 1], + listOf("유럽", "유럽", "아시아")[i - 1], + "지역 설명 $i", + LocalDateTime.of(2024, 1, i, 0, 0), + LocalDateTime.of(2024, 6, i, 0, 0), + null + ) + } - fun createAdminDistilleryItems(count: Int = 3): List = - (1..count).map { i -> - AdminDistilleryItem( - i.toLong(), - listOf("글렌피딕", "맥캘란", "야마자키")[i - 1], - listOf("Glenfiddich", "Macallan", "Yamazaki")[i - 1], - "https://example.com/logo$i.png", - LocalDateTime.of(2024, 1, i, 0, 0), - LocalDateTime.of(2024, 6, i, 0, 0) - ) - } + fun createAdminDistilleryItems(count: Int = 3): List = (1..count).map { i -> + AdminDistilleryItem( + i.toLong(), + listOf("글렌피딕", "맥캘란", "야마자키")[i - 1], + listOf("Glenfiddich", "Macallan", "Yamazaki")[i - 1], + "https://example.com/logo$i.png", + LocalDateTime.of(2024, 1, i, 0, 0), + LocalDateTime.of(2024, 6, i, 0, 0) + ) + } - fun createListResponse(items: List): GlobalResponse = - GlobalResponse.builder() - .success(true) - .code(200) - .data(items) - .errors(emptyList()) - .meta( - mapOf( - "serverVersion" to "1.0.0", - "serverEncoding" to "UTF-8", - "serverResponseTime" to LocalDateTime.now().toString(), - "serverPathVersion" to "v1" - ) + fun createListResponse(items: List): GlobalResponse = GlobalResponse + .builder() + .success(true) + .code(200) + .data(items) + .errors(emptyList()) + .meta( + mapOf( + "serverVersion" to "1.0.0", + "serverEncoding" to "UTF-8", + "serverResponseTime" to LocalDateTime.now().toString(), + "serverPathVersion" to "v1" ) - .build() + ).build() fun createAdminResultResponse( code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.ALCOHOL_CREATED, diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt index 1a3bef9a6..a5e398c9e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/auth/AuthHelper.kt @@ -4,11 +4,8 @@ import app.bottlenote.user.dto.response.TokenItem import org.apache.commons.lang3.RandomStringUtils object AuthHelper { - - fun createTokenItem(): TokenItem { - return TokenItem( - RandomStringUtils.randomAlphanumeric(32), - RandomStringUtils.randomAlphanumeric(32) - ) - } -} \ No newline at end of file + fun createTokenItem(): TokenItem = TokenItem( + RandomStringUtils.randomAlphanumeric(32), + RandomStringUtils.randomAlphanumeric(32) + ) +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt index 2ce810118..b256d7bdc 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/banner/BannerHelper.kt @@ -9,130 +9,149 @@ import app.bottlenote.global.dto.response.AdminResultResponse import java.time.LocalDateTime object BannerHelper { + fun createAdminBannerListResponse( + id: Long = 1L, + name: String = "테스트 배너", + mediaType: MediaType = MediaType.IMAGE, + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + isActive: Boolean = true, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) + ): AdminBannerListResponse = AdminBannerListResponse( + id, + name, + mediaType, + bannerType, + sortOrder, + isActive, + startDate, + endDate, + createdAt + ) - fun createAdminBannerListResponse( - id: Long = 1L, - name: String = "테스트 배너", - mediaType: MediaType = MediaType.IMAGE, - bannerType: BannerType = BannerType.CURATION, - sortOrder: Int = 0, - isActive: Boolean = true, - startDate: LocalDateTime? = null, - endDate: LocalDateTime? = null, - createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) - ): AdminBannerListResponse = AdminBannerListResponse( - id, name, mediaType, bannerType, sortOrder, isActive, startDate, endDate, createdAt - ) + fun createAdminBannerListResponses(count: Int = 3): List = (1..count).map { i -> + createAdminBannerListResponse( + id = i.toLong(), + name = "배너 $i", + sortOrder = i - 1, + createdAt = LocalDateTime.of(2024, i, 1, 0, 0) + ) + } - fun createAdminBannerListResponses(count: Int = 3): List = - (1..count).map { i -> - createAdminBannerListResponse( - id = i.toLong(), - name = "배너 $i", - sortOrder = i - 1, - createdAt = LocalDateTime.of(2024, i, 1, 0, 0) - ) - } + fun createAdminBannerDetailResponse( + id: Long = 1L, + name: String = "테스트 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: TextPosition = TextPosition.RT, + isExternalUrl: Boolean = false, + targetUrl: String? = null, + mediaType: MediaType = MediaType.IMAGE, + bannerType: BannerType = BannerType.CURATION, + sortOrder: Int = 0, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, + isActive: Boolean = true, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + ): AdminBannerDetailResponse = AdminBannerDetailResponse( + id, + name, + nameFontColor, + descriptionA, + descriptionB, + descriptionFontColor, + imageUrl, + textPosition, + isExternalUrl, + targetUrl, + mediaType, + bannerType, + sortOrder, + startDate, + endDate, + isActive, + createdAt, + modifiedAt + ) - fun createAdminBannerDetailResponse( - id: Long = 1L, - name: String = "테스트 배너", - nameFontColor: String = "#ffffff", - descriptionA: String? = "배너 설명A", - descriptionB: String? = "배너 설명B", - descriptionFontColor: String = "#ffffff", - imageUrl: String = "https://example.com/banner.jpg", - textPosition: TextPosition = TextPosition.RT, - isExternalUrl: Boolean = false, - targetUrl: String? = null, - mediaType: MediaType = MediaType.IMAGE, - bannerType: BannerType = BannerType.CURATION, - sortOrder: Int = 0, - startDate: LocalDateTime? = null, - endDate: LocalDateTime? = null, - isActive: Boolean = true, - createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), - modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) - ): AdminBannerDetailResponse = AdminBannerDetailResponse( - id, name, nameFontColor, descriptionA, descriptionB, descriptionFontColor, - imageUrl, textPosition, isExternalUrl, targetUrl, mediaType, bannerType, sortOrder, - startDate, endDate, isActive, createdAt, modifiedAt - ) + fun createBannerCreateRequest( + name: String = "새 배너", + nameFontColor: String = "#ffffff", + descriptionA: String? = "배너 설명A", + descriptionB: String? = "배너 설명B", + descriptionFontColor: String = "#ffffff", + imageUrl: String = "https://example.com/banner.jpg", + textPosition: String = "RT", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + mediaType: String = "IMAGE", + bannerType: String = "CURATION", + sortOrder: Int = 0, + startDate: String? = null, + endDate: String? = null + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "mediaType" to mediaType, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate + ) - fun createBannerCreateRequest( - name: String = "새 배너", - nameFontColor: String = "#ffffff", - descriptionA: String? = "배너 설명A", - descriptionB: String? = "배너 설명B", - descriptionFontColor: String = "#ffffff", - imageUrl: String = "https://example.com/banner.jpg", - textPosition: String = "RT", - isExternalUrl: Boolean = false, - targetUrl: String? = null, - mediaType: String = "IMAGE", - bannerType: String = "CURATION", - sortOrder: Int = 0, - startDate: String? = null, - endDate: String? = null - ): Map = mapOf( - "name" to name, - "nameFontColor" to nameFontColor, - "descriptionA" to descriptionA, - "descriptionB" to descriptionB, - "descriptionFontColor" to descriptionFontColor, - "imageUrl" to imageUrl, - "textPosition" to textPosition, - "isExternalUrl" to isExternalUrl, - "targetUrl" to targetUrl, - "mediaType" to mediaType, - "bannerType" to bannerType, - "sortOrder" to sortOrder, - "startDate" to startDate, - "endDate" to endDate - ) + fun createBannerUpdateRequest( + name: String = "수정된 배너", + nameFontColor: String = "#000000", + descriptionA: String? = "수정된 설명A", + descriptionB: String? = "수정된 설명B", + descriptionFontColor: String = "#000000", + imageUrl: String = "https://example.com/updated.jpg", + textPosition: String = "CENTER", + isExternalUrl: Boolean = false, + targetUrl: String? = null, + mediaType: String = "IMAGE", + bannerType: String = "CURATION", + sortOrder: Int = 1, + startDate: String? = null, + endDate: String? = null, + isActive: Boolean = true + ): Map = mapOf( + "name" to name, + "nameFontColor" to nameFontColor, + "descriptionA" to descriptionA, + "descriptionB" to descriptionB, + "descriptionFontColor" to descriptionFontColor, + "imageUrl" to imageUrl, + "textPosition" to textPosition, + "isExternalUrl" to isExternalUrl, + "targetUrl" to targetUrl, + "mediaType" to mediaType, + "bannerType" to bannerType, + "sortOrder" to sortOrder, + "startDate" to startDate, + "endDate" to endDate, + "isActive" to isActive + ) - fun createBannerUpdateRequest( - name: String = "수정된 배너", - nameFontColor: String = "#000000", - descriptionA: String? = "수정된 설명A", - descriptionB: String? = "수정된 설명B", - descriptionFontColor: String = "#000000", - imageUrl: String = "https://example.com/updated.jpg", - textPosition: String = "CENTER", - isExternalUrl: Boolean = false, - targetUrl: String? = null, - mediaType: String = "IMAGE", - bannerType: String = "CURATION", - sortOrder: Int = 1, - startDate: String? = null, - endDate: String? = null, - isActive: Boolean = true - ): Map = mapOf( - "name" to name, - "nameFontColor" to nameFontColor, - "descriptionA" to descriptionA, - "descriptionB" to descriptionB, - "descriptionFontColor" to descriptionFontColor, - "imageUrl" to imageUrl, - "textPosition" to textPosition, - "isExternalUrl" to isExternalUrl, - "targetUrl" to targetUrl, - "mediaType" to mediaType, - "bannerType" to bannerType, - "sortOrder" to sortOrder, - "startDate" to startDate, - "endDate" to endDate, - "isActive" to isActive - ) + fun createBannerStatusRequest(isActive: Boolean = true): Map = mapOf("isActive" to isActive) - fun createBannerStatusRequest(isActive: Boolean = true): Map = - mapOf("isActive" to isActive) + fun createBannerSortOrderRequest(sortOrder: Int = 1): Map = mapOf("sortOrder" to sortOrder) - fun createBannerSortOrderRequest(sortOrder: Int = 1): Map = - mapOf("sortOrder" to sortOrder) - - fun createAdminResultResponse( - code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.BANNER_CREATED, - targetId: Long = 1L - ): AdminResultResponse = AdminResultResponse.of(code, targetId) + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.BANNER_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt index 0900680a5..2d4e82441 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/helper/curation/CurationHelper.kt @@ -3,89 +3,97 @@ package app.helper.curation import app.bottlenote.alcohols.dto.response.AdminAlcoholItem import app.bottlenote.alcohols.dto.response.AdminCurationDetailResponse import app.bottlenote.alcohols.dto.response.AdminCurationListResponse -import app.helper.alcohols.AlcoholsHelper import app.bottlenote.global.dto.response.AdminResultResponse +import app.helper.alcohols.AlcoholsHelper import java.time.LocalDateTime object CurationHelper { + fun createAdminCurationListResponse( + id: Long = 1L, + name: String = "테스트 큐레이션", + alcoholCount: Int = 5, + displayOrder: Int = 1, + isActive: Boolean = true, + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) + ): AdminCurationListResponse = AdminCurationListResponse( + id, + name, + alcoholCount, + displayOrder, + isActive, + createdAt + ) - fun createAdminCurationListResponse( - id: Long = 1L, - name: String = "테스트 큐레이션", - alcoholCount: Int = 5, - displayOrder: Int = 1, - isActive: Boolean = true, - createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0) - ): AdminCurationListResponse = AdminCurationListResponse( - id, name, alcoholCount, displayOrder, isActive, createdAt - ) - - fun createAdminCurationListResponses(count: Int = 3): List = - (1..count).map { i -> - createAdminCurationListResponse( - id = i.toLong(), - name = "큐레이션 $i", - alcoholCount = i * 2, - displayOrder = i, - createdAt = LocalDateTime.of(2024, i, 1, 0, 0) - ) - } + fun createAdminCurationListResponses(count: Int = 3): List = (1..count).map { i -> + createAdminCurationListResponse( + id = i.toLong(), + name = "큐레이션 $i", + alcoholCount = i * 2, + displayOrder = i, + createdAt = LocalDateTime.of(2024, i, 1, 0, 0) + ) + } - fun createAdminCurationDetailResponse( - id: Long = 1L, - name: String = "테스트 큐레이션", - description: String = "큐레이션 설명입니다.", - coverImageUrl: String = "https://example.com/cover.jpg", - displayOrder: Int = 1, - isActive: Boolean = true, - alcohols: List = AlcoholsHelper.createAdminAlcoholItems(3), - createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), - modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) - ): AdminCurationDetailResponse = AdminCurationDetailResponse( - id, name, description, coverImageUrl, displayOrder, isActive, alcohols, createdAt, modifiedAt - ) + fun createAdminCurationDetailResponse( + id: Long = 1L, + name: String = "테스트 큐레이션", + description: String = "큐레이션 설명입니다.", + coverImageUrl: String = "https://example.com/cover.jpg", + displayOrder: Int = 1, + isActive: Boolean = true, + alcohols: List = AlcoholsHelper.createAdminAlcoholItems(3), + createdAt: LocalDateTime = LocalDateTime.of(2024, 1, 1, 0, 0), + modifiedAt: LocalDateTime = LocalDateTime.of(2024, 6, 1, 0, 0) + ): AdminCurationDetailResponse = AdminCurationDetailResponse( + id, + name, + description, + coverImageUrl, + displayOrder, + isActive, + alcohols, + createdAt, + modifiedAt + ) - fun createCurationCreateRequest( - name: String = "새 큐레이션", - description: String? = "큐레이션 설명", - coverImageUrl: String? = "https://example.com/cover.jpg", - displayOrder: Int = 0, - alcoholIds: Set = emptySet() - ): Map = mapOf( - "name" to name, - "description" to description, - "coverImageUrl" to coverImageUrl, - "displayOrder" to displayOrder, - "alcoholIds" to alcoholIds - ) + fun createCurationCreateRequest( + name: String = "새 큐레이션", + description: String? = "큐레이션 설명", + coverImageUrl: String? = "https://example.com/cover.jpg", + displayOrder: Int = 0, + alcoholIds: Set = emptySet() + ): Map = mapOf( + "name" to name, + "description" to description, + "coverImageUrl" to coverImageUrl, + "displayOrder" to displayOrder, + "alcoholIds" to alcoholIds + ) - fun createCurationUpdateRequest( - name: String = "수정된 큐레이션", - description: String? = "수정된 설명", - coverImageUrl: String? = "https://example.com/updated.jpg", - displayOrder: Int = 1, - isActive: Boolean = true, - alcoholIds: Set = setOf(1L, 2L) - ): Map = mapOf( - "name" to name, - "description" to description, - "coverImageUrl" to coverImageUrl, - "displayOrder" to displayOrder, - "isActive" to isActive, - "alcoholIds" to alcoholIds - ) + fun createCurationUpdateRequest( + name: String = "수정된 큐레이션", + description: String? = "수정된 설명", + coverImageUrl: String? = "https://example.com/updated.jpg", + displayOrder: Int = 1, + isActive: Boolean = true, + alcoholIds: Set = setOf(1L, 2L) + ): Map = mapOf( + "name" to name, + "description" to description, + "coverImageUrl" to coverImageUrl, + "displayOrder" to displayOrder, + "isActive" to isActive, + "alcoholIds" to alcoholIds + ) - fun createCurationStatusRequest(isActive: Boolean = true): Map = - mapOf("isActive" to isActive) + fun createCurationStatusRequest(isActive: Boolean = true): Map = mapOf("isActive" to isActive) - fun createCurationDisplayOrderRequest(displayOrder: Int = 1): Map = - mapOf("displayOrder" to displayOrder) + fun createCurationDisplayOrderRequest(displayOrder: Int = 1): Map = mapOf("displayOrder" to displayOrder) - fun createCurationAlcoholRequest(alcoholIds: Set = setOf(1L, 2L)): Map = - mapOf("alcoholIds" to alcoholIds) + fun createCurationAlcoholRequest(alcoholIds: Set = setOf(1L, 2L)): Map = mapOf("alcoholIds" to alcoholIds) - fun createAdminResultResponse( - code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.CURATION_CREATED, - targetId: Long = 1L - ): AdminResultResponse = AdminResultResponse.of(code, targetId) + fun createAdminResultResponse( + code: AdminResultResponse.ResultCode = AdminResultResponse.ResultCode.CURATION_CREATED, + targetId: Long = 1L + ): AdminResultResponse = AdminResultResponse.of(code, targetId) } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt index 75f51746f..69a775054 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminAlcoholsIntegrationTest.kt @@ -28,7 +28,6 @@ import java.util.stream.Stream @Tag("admin_integration") @DisplayName("[integration] Admin Alcohol API 통합 테스트") class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var alcoholTestFactory: AlcoholTestFactory @@ -79,31 +78,39 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @ParameterizedTest(name = "{2}") @MethodSource("keywordSearchTestCases") @DisplayName("키워드로 술을 검색할 수 있다") - fun searchByKeyword(keyword: String, expectedCount: Int, description: String) { + fun searchByKeyword( + keyword: String, + expectedCount: Int, + description: String + ) { // given alcoholTestFactory.persistAlcoholWithName("글렌피딕 12년", "Glenfiddich 12") alcoholTestFactory.persistAlcoholWithName("맥캘란 18년", "Macallan 18") // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .param("keyword", keyword) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()").isEqualTo(expectedCount) + .extractingPath("$.data.length()") + .isEqualTo(expectedCount) } @ParameterizedTest(name = "카테고리: {0}") @@ -115,33 +122,40 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .param("category", category.name) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @ParameterizedTest(name = "정렬: {0} {1}") @MethodSource("sortTypeTestCases") @DisplayName("다양한 정렬 조건으로 조회할 수 있다") - fun sortByVariousTypes(sortType: AdminAlcoholSortType, sortOrder: SortOrder) { + fun sortByVariousTypes( + sortType: AdminAlcoholSortType, + sortOrder: SortOrder + ) { // given alcoholTestFactory.persistAlcoholWithName("가나다 위스키", "ABC Whisky") alcoholTestFactory.persistAlcoholWithName("마바사 위스키", "DEF Whisky") // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .param("sortType", sortType.name) .param("sortOrder", sortOrder.name) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @ParameterizedTest(name = "page={0}, size={1}") @@ -152,26 +166,30 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { "2, 5" ) @DisplayName("페이징이 정상 동작한다") - fun pagination(page: Int, size: Int) { + fun pagination( + page: Int, + size: Int + ) { // given alcoholTestFactory.persistAlcohols(25) // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .param("page", page.toString()) .param("size", size.toString()) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.meta.size").isEqualTo(size) + .extractingPath("$.meta.size") + .isEqualTo(size) } @Nested @DisplayName("삭제 데이터 필터링") inner class DeletedAlcoholFiltering { - @Test @DisplayName("기본 조회 시 삭제된 위스키는 제외된다") fun excludeDeletedByDefault() { @@ -181,12 +199,14 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()").isEqualTo(3) + .extractingPath("$.data.length()") + .isEqualTo(3) } @Test @@ -198,20 +218,21 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/alcohols") + mockMvcTester + .get() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .param("includeDeleted", "true") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()").isEqualTo(4) + .extractingPath("$.data.length()") + .isEqualTo(4) } } @Nested @DisplayName("카테고리 레퍼런스 조회 API") inner class GetCategoryReference { - @Test @DisplayName("기존 카테고리 페어 목록을 조회할 수 있다") fun getCategoryReferenceSuccess() { @@ -222,12 +243,14 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/alcohols/categories/reference") + mockMvcTester + .get() + .uri("/alcohols/categories/reference") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -239,19 +262,20 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then - 2개의 다른 페어가 반환됨 assertThat( - mockMvcTester.get().uri("/alcohols/categories/reference") + mockMvcTester + .get() + .uri("/alcohols/categories/reference") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.length()").isEqualTo(2) + .extractingPath("$.data.length()") + .isEqualTo(2) } } @Nested @DisplayName("술 단건 상세 조회 API") inner class GetAlcoholDetail { - @Test @DisplayName("관리자용 술 단건 상세 정보를 조회할 수 있다") fun getAlcoholDetailSuccess() { @@ -260,21 +284,25 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then - 성공 응답 확인 assertThat( - mockMvcTester.get().uri("/alcohols/${alcohol.id}") + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) // 상세 데이터 검증 assertThat( - mockMvcTester.get().uri("/alcohols/${alcohol.id}") + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.korName").isEqualTo("글렌피딕 12년") + .extractingPath("$.data.korName") + .isEqualTo("글렌피딕 12년") } @Test @@ -284,27 +312,31 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val alcohol = alcoholTestFactory.persistAlcoholWithName("맥캘란 18년", "Macallan 18") // when & then - 필수 필드 존재 여부 확인 - val result = mockMvcTester.get().uri("/alcohols/${alcohol.id}") - .header("Authorization", "Bearer $accessToken") + val result = + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") assertThat(result) .hasStatusOk() .bodyJson() - .extractingPath("$.data.alcoholId").isNotNull + .extractingPath("$.data.alcoholId") + .isNotNull // 방어로직: 존재하지 않는 ID로 조회 시 실패 assertThat( - mockMvcTester.get().uri("/alcohols/999999") + mockMvcTester + .get() + .uri("/alcohols/999999") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("술 생성 API") inner class CreateAlcohol { - @Test @DisplayName("위스키를 생성할 수 있다") fun createAlcoholSuccess() { @@ -312,33 +344,36 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val region = alcoholTestFactory.persistRegion() val distillery = alcoholTestFactory.persistDistillery() - val request = mapOf( - "korName" to "테스트 위스키", - "engName" to "Test Whisky", - "abv" to "40%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "12", - "cask" to "American Oak", - "imageUrl" to "https://example.com/test.jpg", - "description" to "테스트 설명", - "volume" to "700ml" - ) + val request = + mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) // when & then assertThat( - mockMvcTester.post().uri("/alcohols") + mockMvcTester + .post() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") + .extractingPath("$.data.code") + .isEqualTo("ALCOHOL_CREATED") } @Test @@ -350,45 +385,52 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val tag1 = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") val tag2 = tastingTagTestFactory.persistTastingTag("꿀", "Honey") - val request = mapOf( - "korName" to "테스트 위스키", - "engName" to "Test Whisky", - "abv" to "40%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "12", - "cask" to "American Oak", - "imageUrl" to "https://example.com/test.jpg", - "description" to "테스트 설명", - "volume" to "700ml", - "tastingTagIds" to listOf(tag1.id, tag2.id) - ) + val request = + mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml", + "tastingTagIds" to listOf(tag1.id, tag2.id) + ) // when - val createResult = mockMvcTester.post().uri("/alcohols") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .exchange() + val createResult = + mockMvcTester + .post() + .uri("/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .exchange() assertThat(createResult) .hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("ALCOHOL_CREATED") + .extractingPath("$.data.code") + .isEqualTo("ALCOHOL_CREATED") // then - 상세 조회로 태그 확인 val alcoholId = extractData(createResult, Map::class.java)["targetId"] assertThat( - mockMvcTester.get().uri("/alcohols/$alcoholId") + mockMvcTester + .get() + .uri("/alcohols/$alcoholId") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tastingTags.length()").isEqualTo(2) + .extractingPath("$.data.tastingTags.length()") + .isEqualTo(2) } @Test @@ -398,50 +440,54 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val region = alcoholTestFactory.persistRegion() val distillery = alcoholTestFactory.persistDistillery() - val request = mapOf( - "korName" to "테스트 위스키", - "engName" to "Test Whisky", - "abv" to "40%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "12", - "cask" to "American Oak", - "imageUrl" to "https://example.com/test.jpg", - "description" to "테스트 설명", - "volume" to "700ml", - "tastingTagIds" to listOf(999999L) - ) + val request = + mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml", + "tastingTagIds" to listOf(999999L) + ) // when & then assertThat( - mockMvcTester.post().uri("/alcohols") + mockMvcTester + .post() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @DisplayName("필수 필드 누락 시 실패한다") fun createAlcoholWithMissingFields() { // given - val request = mapOf( - "korName" to "테스트 위스키" - ) + val request = + mapOf( + "korName" to "테스트 위스키" + ) // when & then assertThat( - mockMvcTester.post().uri("/alcohols") + mockMvcTester + .post() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -450,38 +496,39 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // given val distillery = alcoholTestFactory.persistDistillery() - val request = mapOf( - "korName" to "테스트 위스키", - "engName" to "Test Whisky", - "abv" to "40%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to 999999L, - "distilleryId" to distillery.id, - "age" to "12", - "cask" to "American Oak", - "imageUrl" to "https://example.com/test.jpg", - "description" to "테스트 설명", - "volume" to "700ml" - ) + val request = + mapOf( + "korName" to "테스트 위스키", + "engName" to "Test Whisky", + "abv" to "40%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to 999999L, + "distilleryId" to distillery.id, + "age" to "12", + "cask" to "American Oak", + "imageUrl" to "https://example.com/test.jpg", + "description" to "테스트 설명", + "volume" to "700ml" + ) // when & then assertThat( - mockMvcTester.post().uri("/alcohols") + mockMvcTester + .post() + .uri("/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("술 수정 API") inner class UpdateAlcohol { - @Test @DisplayName("위스키를 수정할 수 있다") fun updateAlcoholSuccess() { @@ -490,33 +537,36 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val region = alcoholTestFactory.persistRegion() val distillery = alcoholTestFactory.persistDistillery() - val request = mapOf( - "korName" to "수정된 위스키", - "engName" to "Updated Whisky", - "abv" to "43%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "18", - "cask" to "Sherry Oak", - "imageUrl" to "https://example.com/updated.jpg", - "description" to "수정된 설명", - "volume" to "750ml" - ) + val request = + mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) // when & then assertThat( - mockMvcTester.put().uri("/alcohols/${alcohol.id}") + mockMvcTester + .put() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") + .extractingPath("$.data.code") + .isEqualTo("ALCOHOL_UPDATED") } @Test @@ -531,43 +581,48 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val newTag2 = tastingTagTestFactory.persistTastingTag("스모키", "Smoky") tastingTagTestFactory.linkAlcoholToTag(alcohol, oldTag) - val request = mapOf( - "korName" to "수정된 위스키", - "engName" to "Updated Whisky", - "abv" to "43%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "18", - "cask" to "Sherry Oak", - "imageUrl" to "https://example.com/updated.jpg", - "description" to "수정된 설명", - "volume" to "750ml", - "tastingTagIds" to listOf(newTag1.id, newTag2.id) - ) + val request = + mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml", + "tastingTagIds" to listOf(newTag1.id, newTag2.id) + ) // when assertThat( - mockMvcTester.put().uri("/alcohols/${alcohol.id}") + mockMvcTester + .put() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("ALCOHOL_UPDATED") + .extractingPath("$.data.code") + .isEqualTo("ALCOHOL_UPDATED") // then - 상세 조회로 태그 교체 확인 assertThat( - mockMvcTester.get().uri("/alcohols/${alcohol.id}") + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tastingTags.length()").isEqualTo(2) + .extractingPath("$.data.tastingTags.length()") + .isEqualTo(2) } @Test @@ -580,40 +635,44 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val tag = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) - val request = mapOf( - "korName" to "수정된 위스키", - "engName" to "Updated Whisky", - "abv" to "43%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "18", - "cask" to "Sherry Oak", - "imageUrl" to "https://example.com/updated.jpg", - "description" to "수정된 설명", - "volume" to "750ml" - ) + val request = + mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) // when assertThat( - mockMvcTester.put().uri("/alcohols/${alcohol.id}") + mockMvcTester + .put() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() // then - 기존 태그 유지 확인 assertThat( - mockMvcTester.get().uri("/alcohols/${alcohol.id}") + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tastingTags.length()").isEqualTo(1) + .extractingPath("$.data.tastingTags.length()") + .isEqualTo(1) } @Test @@ -626,41 +685,45 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val tag = tastingTagTestFactory.persistTastingTag("바닐라", "Vanilla") tastingTagTestFactory.linkAlcoholToTag(alcohol, tag) - val request = mapOf( - "korName" to "수정된 위스키", - "engName" to "Updated Whisky", - "abv" to "43%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "18", - "cask" to "Sherry Oak", - "imageUrl" to "https://example.com/updated.jpg", - "description" to "수정된 설명", - "volume" to "750ml", - "tastingTagIds" to emptyList() - ) + val request = + mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml", + "tastingTagIds" to emptyList() + ) // when assertThat( - mockMvcTester.put().uri("/alcohols/${alcohol.id}") + mockMvcTester + .put() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() // then - 태그 전부 삭제 확인 assertThat( - mockMvcTester.get().uri("/alcohols/${alcohol.id}") + mockMvcTester + .get() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tastingTags.length()").isEqualTo(0) + .extractingPath("$.data.tastingTags.length()") + .isEqualTo(0) } @Test @@ -670,38 +733,39 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { val region = alcoholTestFactory.persistRegion() val distillery = alcoholTestFactory.persistDistillery() - val request = mapOf( - "korName" to "수정된 위스키", - "engName" to "Updated Whisky", - "abv" to "43%", - "type" to AlcoholType.WHISKY.name, - "korCategory" to "싱글 몰트", - "engCategory" to "Single Malt", - "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, - "regionId" to region.id, - "distilleryId" to distillery.id, - "age" to "18", - "cask" to "Sherry Oak", - "imageUrl" to "https://example.com/updated.jpg", - "description" to "수정된 설명", - "volume" to "750ml" - ) + val request = + mapOf( + "korName" to "수정된 위스키", + "engName" to "Updated Whisky", + "abv" to "43%", + "type" to AlcoholType.WHISKY.name, + "korCategory" to "싱글 몰트", + "engCategory" to "Single Malt", + "categoryGroup" to AlcoholCategoryGroup.SINGLE_MALT.name, + "regionId" to region.id, + "distilleryId" to distillery.id, + "age" to "18", + "cask" to "Sherry Oak", + "imageUrl" to "https://example.com/updated.jpg", + "description" to "수정된 설명", + "volume" to "750ml" + ) // when & then assertThat( - mockMvcTester.put().uri("/alcohols/999999") + mockMvcTester + .put() + .uri("/alcohols/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("술 삭제 API") inner class DeleteAlcohol { - @Test @DisplayName("위스키를 삭제할 수 있다") fun deleteAlcoholSuccess() { @@ -710,12 +774,14 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + mockMvcTester + .delete() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("ALCOHOL_DELETED") + .extractingPath("$.data.code") + .isEqualTo("ALCOHOL_DELETED") } @Test @@ -723,10 +789,11 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { fun deleteAlcoholNotFound() { // when & then assertThat( - mockMvcTester.delete().uri("/alcohols/999999") + mockMvcTester + .delete() + .uri("/alcohols/999999") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -739,10 +806,11 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + mockMvcTester + .delete() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -755,10 +823,11 @@ class AdminAlcoholsIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/alcohols/${alcohol.id}") + mockMvcTester + .delete() + .uri("/alcohols/${alcohol.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt index 3cd4a670e..0f568561e 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminReferenceDataIntegrationTest.kt @@ -13,7 +13,6 @@ import org.springframework.beans.factory.annotation.Autowired @Tag("admin_integration") @DisplayName("[integration] Admin 참조 데이터 API 통합 테스트") class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var alcoholTestFactory: AlcoholTestFactory @@ -28,27 +27,30 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { @Nested @DisplayName("테이스팅 태그 목록 조회 API") inner class TastingTagsApi { - @Test @DisplayName("전체 테이스팅 태그 목록을 페이지네이션으로 조회할 수 있다") fun getAllTastingTagsSuccess() { // when & then assertThat( - mockMvcTester.get().uri("/tasting-tags?page=0&size=20") + mockMvcTester + .get() + .uri("/tasting-tags?page=0&size=20") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) // 페이지네이션 메타 정보 확인 assertThat( - mockMvcTester.get().uri("/tasting-tags?page=0&size=10") + mockMvcTester + .get() + .uri("/tasting-tags?page=0&size=10") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.meta.size").isEqualTo(10) + .extractingPath("$.meta.size") + .isEqualTo(10) } @Test @@ -57,15 +59,13 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { // when & then - 방어로직: 인증 없이 요청 시 실패 assertThat( mockMvcTester.get().uri("/tasting-tags") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("지역 목록 조회 API") inner class RegionsApi { - @Test @DisplayName("전체 지역 목록을 페이지네이션으로 조회할 수 있다") fun getAllRegionsSuccess() { @@ -74,12 +74,14 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/regions?page=0&size=20") + mockMvcTester + .get() + .uri("/regions?page=0&size=20") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -89,30 +91,33 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { alcoholTestFactory.persistAlcohols(1) // when & then - 응답 데이터 및 페이지네이션 메타 확인 - val result = mockMvcTester.get().uri("/regions?page=0&size=10") - .header("Authorization", "Bearer $accessToken") + val result = + mockMvcTester + .get() + .uri("/regions?page=0&size=10") + .header("Authorization", "Bearer $accessToken") assertThat(result) .hasStatusOk() .bodyJson() - .extractingPath("$.meta.page").isEqualTo(0) + .extractingPath("$.meta.page") + .isEqualTo(0) assertThat(result) .bodyJson() - .extractingPath("$.meta.totalElements").isNotNull + .extractingPath("$.meta.totalElements") + .isNotNull // 방어로직: 인증 없이 요청 시 실패 assertThat( mockMvcTester.get().uri("/regions") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("증류소 목록 조회 API") inner class DistilleriesApi { - @Test @DisplayName("전체 증류소 목록을 페이지네이션으로 조회할 수 있다") fun getAllDistilleriesSuccess() { @@ -121,12 +126,14 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/distilleries?page=0&size=20") + mockMvcTester + .get() + .uri("/distilleries?page=0&size=20") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -136,19 +143,22 @@ class AdminReferenceDataIntegrationTest : IntegrationTestSupport() { alcoholTestFactory.persistAlcohols(1) // when & then - 키워드 검색 - val result = mockMvcTester.get().uri("/distilleries?keyword=&page=0&size=20") - .header("Authorization", "Bearer $accessToken") + val result = + mockMvcTester + .get() + .uri("/distilleries?keyword=&page=0&size=20") + .header("Authorization", "Bearer $accessToken") assertThat(result) .hasStatusOk() .bodyJson() - .extractingPath("$.data").isNotNull + .extractingPath("$.data") + .isNotNull // 방어로직: 인증 없이 요청 시 실패 assertThat( mockMvcTester.get().uri("/distilleries") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt index 456bfe04b..e78ae78fa 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/alcohols/AdminTastingTagIntegrationTest.kt @@ -15,7 +15,6 @@ import org.springframework.http.MediaType @Tag("admin_integration") @DisplayName("[integration] Admin TastingTag API 통합 테스트") class AdminTastingTagIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var tastingTagTestFactory: TastingTagTestFactory @@ -33,7 +32,6 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { @Nested @DisplayName("테이스팅 태그 단건 조회 API") inner class GetTagDetail { - @Test @DisplayName("테이스팅 태그 상세 정보를 조회할 수 있다") fun getTagDetailSuccess() { @@ -42,20 +40,24 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/tasting-tags/${tag.id}") + mockMvcTester + .get() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) assertThat( - mockMvcTester.get().uri("/tasting-tags/${tag.id}") + mockMvcTester + .get() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tag.korName").isEqualTo("허니") + .extractingPath("$.data.tag.korName") + .isEqualTo("허니") } @Test @@ -67,20 +69,24 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then - leaf 태그 조회 시 parent.parent 존재 확인 assertThat( - mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") + mockMvcTester + .get() + .uri("/tasting-tags/${leafTag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tag.parent").isNotNull() + .extractingPath("$.data.tag.parent") + .isNotNull() assertThat( - mockMvcTester.get().uri("/tasting-tags/${leafTag.id}") + mockMvcTester + .get() + .uri("/tasting-tags/${leafTag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.tag.parent.parent").isNotNull() + .extractingPath("$.data.tag.parent.parent") + .isNotNull() } @Test @@ -93,12 +99,14 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/tasting-tags/${tag.id}") + mockMvcTester + .get() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.alcohols.length()").isEqualTo(1) + .extractingPath("$.data.alcohols.length()") + .isEqualTo(1) } @Test @@ -106,37 +114,40 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { fun getTagDetailNotFound() { // when & then assertThat( - mockMvcTester.get().uri("/tasting-tags/999999") + mockMvcTester + .get() + .uri("/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("테이스팅 태그 생성 API") inner class CreateTag { - @Test @DisplayName("테이스팅 태그를 생성할 수 있다") fun createTagSuccess() { // given - val request = mapOf( - "korName" to "새로운 태그", - "engName" to "New Tag", - "description" to "테스트 설명" - ) + val request = + mapOf( + "korName" to "새로운 태그", + "engName" to "New Tag", + "description" to "테스트 설명" + ) // when & then assertThat( - mockMvcTester.post().uri("/tasting-tags") + mockMvcTester + .post() + .uri("/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_CREATED") } @Test @@ -144,22 +155,25 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { fun createTagWithParent() { // given val parent = tastingTagTestFactory.persistTastingTag("부모 태그", "Parent Tag") - val request = mapOf( - "korName" to "자식 태그", - "engName" to "Child Tag", - "parentId" to parent.id - ) + val request = + mapOf( + "korName" to "자식 태그", + "engName" to "Child Tag", + "parentId" to parent.id + ) // when & then assertThat( - mockMvcTester.post().uri("/tasting-tags") + mockMvcTester + .post() + .uri("/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_CREATED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_CREATED") } @Test @@ -167,19 +181,21 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { fun createTagDuplicateName() { // given tastingTagTestFactory.persistTastingTag("중복 태그", "Duplicate Tag") - val request = mapOf( - "korName" to "중복 태그", - "engName" to "Another Tag" - ) + val request = + mapOf( + "korName" to "중복 태그", + "engName" to "Another Tag" + ) // when & then assertThat( - mockMvcTester.post().uri("/tasting-tags") + mockMvcTester + .post() + .uri("/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -189,48 +205,52 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { val tree = tastingTagTestFactory.persistTastingTagTree() val leafTag = tree[2] - val request = mapOf( - "korName" to "4depth 태그", - "engName" to "4depth Tag", - "parentId" to leafTag.id - ) + val request = + mapOf( + "korName" to "4depth 태그", + "engName" to "4depth Tag", + "parentId" to leafTag.id + ) // when & then - 4depth 생성 시도 시 실패 assertThat( - mockMvcTester.post().uri("/tasting-tags") + mockMvcTester + .post() + .uri("/tasting-tags") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("테이스팅 태그 수정 API") inner class UpdateTag { - @Test @DisplayName("테이스팅 태그를 수정할 수 있다") fun updateTagSuccess() { // given val tag = tastingTagTestFactory.persistTastingTag() - val request = mapOf( - "korName" to "수정된 태그", - "engName" to "Updated Tag", - "description" to "수정된 설명" - ) + val request = + mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag", + "description" to "수정된 설명" + ) // when & then assertThat( - mockMvcTester.put().uri("/tasting-tags/${tag.id}") + mockMvcTester + .put() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_UPDATED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_UPDATED") } @Test @@ -240,45 +260,48 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { tastingTagTestFactory.persistTastingTag("기존 태그", "Existing Tag") val targetTag = tastingTagTestFactory.persistTastingTag("수정 대상", "Target Tag") - val request = mapOf( - "korName" to "기존 태그", - "engName" to "Updated Tag" - ) + val request = + mapOf( + "korName" to "기존 태그", + "engName" to "Updated Tag" + ) // when & then assertThat( - mockMvcTester.put().uri("/tasting-tags/${targetTag.id}") + mockMvcTester + .put() + .uri("/tasting-tags/${targetTag.id}") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @DisplayName("존재하지 않는 태그 수정 시 실패한다") fun updateTagNotFound() { // given - val request = mapOf( - "korName" to "수정된 태그", - "engName" to "Updated Tag" - ) + val request = + mapOf( + "korName" to "수정된 태그", + "engName" to "Updated Tag" + ) // when & then assertThat( - mockMvcTester.put().uri("/tasting-tags/999999") + mockMvcTester + .put() + .uri("/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("테이스팅 태그 삭제 API") inner class DeleteTag { - @Test @DisplayName("테이스팅 태그를 삭제할 수 있다") fun deleteTagSuccess() { @@ -287,12 +310,14 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + mockMvcTester + .delete() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_DELETED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_DELETED") } @Test @@ -304,10 +329,11 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/tasting-tags/${parent.id}") + mockMvcTester + .delete() + .uri("/tasting-tags/${parent.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -320,10 +346,11 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/tasting-tags/${tag.id}") + mockMvcTester + .delete() + .uri("/tasting-tags/${tag.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -331,17 +358,17 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { fun deleteTagNotFound() { // when & then assertThat( - mockMvcTester.delete().uri("/tasting-tags/999999") + mockMvcTester + .delete() + .uri("/tasting-tags/999999") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("테이스팅 태그 위스키 연결 API") inner class ManageAlcohols { - @Test @DisplayName("위스키를 태그에 벌크로 연결할 수 있다") fun addAlcoholsToTagSuccess() { @@ -354,14 +381,16 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + mockMvcTester + .post() + .uri("/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_ADDED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_ALCOHOL_ADDED") } @Test @@ -378,14 +407,16 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete().uri("/tasting-tags/${tag.id}/alcohols") + mockMvcTester + .delete() + .uri("/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.code").isEqualTo("TASTING_TAG_ALCOHOL_REMOVED") + .extractingPath("$.data.code") + .isEqualTo("TASTING_TAG_ALCOHOL_REMOVED") } @Test @@ -397,19 +428,19 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post().uri("/tasting-tags/${tag.id}/alcohols") + mockMvcTester + .post() + .uri("/tasting-tags/${tag.id}/alcohols") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("인증 테스트") inner class AuthenticationTest { - @Test @DisplayName("인증 없이 요청 시 실패한다") fun requestWithoutAuth() { @@ -418,11 +449,12 @@ class AdminTastingTagIntegrationTest : IntegrationTestSupport() { .hasStatus4xxClientError() assertThat( - mockMvcTester.post().uri("/tasting-tags") + mockMvcTester + .post() + .uri("/tasting-tags") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(mapOf("korName" to "테스트", "engName" to "Test"))) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt index b49a9a080..37280ee08 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/auth/AdminAuthIntegrationTest.kt @@ -12,11 +12,9 @@ import org.springframework.http.MediaType @Tag("admin_integration") @DisplayName("[integration] Admin Auth API 통합 테스트") class AdminAuthIntegrationTest : IntegrationTestSupport() { - @Nested @DisplayName("로그인 API") inner class LoginTest { - @Test @DisplayName("올바른 이메일과 비밀번호로 로그인에 성공한다") fun loginSuccess() { @@ -29,24 +27,26 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.accessToken").isNotNull() + .extractingPath("$.data.accessToken") + .isNotNull() } @Test @@ -61,14 +61,15 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @@ -79,14 +80,15 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @@ -101,14 +103,15 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @@ -122,21 +125,21 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } } @Nested @DisplayName("토큰 갱신 API") inner class RefreshTest { - @Test @DisplayName("유효한 리프레시 토큰으로 토큰 갱신에 성공한다") fun refreshSuccess() { @@ -147,11 +150,13 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // 로그인해서 토큰 획득 val loginRequest = mapOf("email" to email, "password" to password) - val loginResult = mockMvcTester.post() - .uri("/auth/login") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(loginRequest)) - .exchange() + val loginResult = + mockMvcTester + .post() + .uri("/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(loginRequest)) + .exchange() val loginResponse = mapper.readTree(loginResult.response.contentAsString) val refreshToken = loginResponse.path("data").path("refreshToken").asText() @@ -160,14 +165,15 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.accessToken").isNotNull() + .extractingPath("$.data.accessToken") + .isNotNull() } @Test @@ -178,21 +184,21 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/refresh") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } } @Nested @DisplayName("탈퇴 API") inner class WithdrawTest { - @Test @DisplayName("일반 어드민이 탈퇴에 성공한다") fun withdrawSuccess() { @@ -202,13 +208,14 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete() + mockMvcTester + .delete() .uri("/auth/withdraw") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.message").isEqualTo("탈퇴 처리되었습니다.") + .extractingPath("$.data.message") + .isEqualTo("탈퇴 처리되었습니다.") } @Test @@ -220,20 +227,20 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.delete() + mockMvcTester + .delete() .uri("/auth/withdraw") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } } @Nested @DisplayName("회원가입 API") inner class SignupTest { - @Test @DisplayName("인증된 어드민이 새 어드민 계정을 생성할 수 있다") fun signupSuccess() { @@ -241,35 +248,38 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistRootAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to "new@bottlenote.com", - "password" to "password123", - "name" to "새 어드민", - "roles" to listOf("PARTNER") - ) + val request = + mapOf( + "email" to "new@bottlenote.com", + "password" to "password123", + "name" to "새 어드민", + "roles" to listOf("PARTNER") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request.plus("email" to "another@bottlenote.com"))) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.email").isEqualTo("another@bottlenote.com") + .extractingPath("$.data.email") + .isEqualTo("another@bottlenote.com") } @Test @@ -279,24 +289,26 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistRootAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to "multi-role@bottlenote.com", - "password" to "password123", - "name" to "다중 역할 어드민", - "roles" to listOf("PARTNER", "COMMUNITY_MANAGER") - ) + val request = + mapOf( + "email" to "multi-role@bottlenote.com", + "password" to "password123", + "name" to "다중 역할 어드민", + "roles" to listOf("PARTNER", "COMMUNITY_MANAGER") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.roles").isEqualTo(listOf("PARTNER", "COMMUNITY_MANAGER")) + .extractingPath("$.data.roles") + .isEqualTo(listOf("PARTNER", "COMMUNITY_MANAGER")) } @Test @@ -306,24 +318,26 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistRootAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to "new-root@bottlenote.com", - "password" to "password123", - "name" to "새 루트 어드민", - "roles" to listOf("ROOT_ADMIN") - ) + val request = + mapOf( + "email" to "new-root@bottlenote.com", + "password" to "password123", + "name" to "새 루트 어드민", + "roles" to listOf("ROOT_ADMIN") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.roles").isEqualTo(listOf("ROOT_ADMIN")) + .extractingPath("$.data.roles") + .isEqualTo(listOf("ROOT_ADMIN")) } @Test @@ -333,45 +347,48 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistPartnerAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to "root-attempt@bottlenote.com", - "password" to "password123", - "name" to "루트 시도", - "roles" to listOf("ROOT_ADMIN") - ) + val request = + mapOf( + "email" to "root-attempt@bottlenote.com", + "password" to "password123", + "name" to "루트 시도", + "roles" to listOf("ROOT_ADMIN") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @DisplayName("인증되지 않은 사용자는 회원가입 API를 호출할 수 없다") fun signupFailWithoutAuth() { // given - val request = mapOf( - "email" to "no-auth@bottlenote.com", - "password" to "password123", - "name" to "인증 없는 사용자", - "roles" to listOf("PARTNER") - ) + val request = + mapOf( + "email" to "no-auth@bottlenote.com", + "password" to "password123", + "name" to "인증 없는 사용자", + "roles" to listOf("PARTNER") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } @Test @@ -384,24 +401,26 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistRootAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to existingEmail, - "password" to "password123", - "name" to "중복 시도", - "roles" to listOf("PARTNER") - ) + val request = + mapOf( + "email" to existingEmail, + "password" to "password123", + "name" to "중복 시도", + "roles" to listOf("PARTNER") + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @@ -411,22 +430,23 @@ class AdminAuthIntegrationTest : IntegrationTestSupport() { val admin = adminUserTestFactory.persistRootAdmin() val accessToken = getAccessToken(admin) - val request = mapOf( - "email" to "empty-roles@bottlenote.com", - "password" to "password123", - "name" to "역할 없음", - "roles" to emptyList() - ) + val request = + mapOf( + "email" to "empty-roles@bottlenote.com", + "password" to "password123", + "name" to "역할 없음", + "roles" to emptyList() + ) // when & then assertThat( - mockMvcTester.post() + mockMvcTester + .post() .uri("/auth/signup") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt index 442f2ea4c..968deb575 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/banner/AdminBannerIntegrationTest.kt @@ -15,411 +15,438 @@ import org.springframework.http.MediaType @Tag("admin_integration") @DisplayName("[integration] Admin Banner API 통합 테스트") class AdminBannerIntegrationTest : IntegrationTestSupport() { - - @Autowired - private lateinit var bannerTestFactory: BannerTestFactory - - private lateinit var accessToken: String - - @BeforeEach - fun setUp() { - val admin = adminUserTestFactory.persistRootAdmin() - accessToken = getAccessToken(admin) - } - - @Nested - @DisplayName("배너 목록 조회 API") - inner class ListBanners { - - @Test - @DisplayName("배너 목록을 조회할 수 있다") - fun listSuccess() { - // given - bannerTestFactory.persistMultipleBanners(3) - - // when & then - assertThat( - mockMvcTester.get().uri("/banners") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("키워드로 필터링하여 조회할 수 있다") - fun listWithKeywordFilter() { - // given - bannerTestFactory.persistBanner("특별 배너", "https://example.com/special.jpg") - - // when & then - assertThat( - mockMvcTester.get().uri("/banners") - .param("keyword", "특별") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") - fun listWithIsActiveFilter() { - // given - bannerTestFactory.persistMixedActiveBanners(2, 1) - - // when & then - assertThat( - mockMvcTester.get().uri("/banners") - .param("isActive", "true") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("배너 유형으로 필터링하여 조회할 수 있다") - fun listWithBannerTypeFilter() { - // given - bannerTestFactory.persistMixedActiveBanners(2, 1) - - // when & then - assertThat( - mockMvcTester.get().uri("/banners") - .param("bannerType", "CURATION") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("인증 없이 요청하면 401을 반환한다") - fun listUnauthorized() { - assertThat( - mockMvcTester.get().uri("/banners") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 상세 조회 API") - inner class GetBannerDetail { - - @Test - @DisplayName("배너 상세 정보를 조회할 수 있다") - fun getDetailSuccess() { - // given - val banner = bannerTestFactory.persistBanner("상세 배너", "https://example.com/detail.jpg") - - // when & then - assertThat( - mockMvcTester.get().uri("/banners/${banner.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("존재하지 않는 배너 조회 시 404를 반환한다") - fun getDetailNotFound() { - // when & then - assertThat( - mockMvcTester.get().uri("/banners/999999") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 생성 API") - inner class CreateBanner { - - @Test - @DisplayName("배너를 생성할 수 있다") - fun createSuccess() { - // given - val request = BannerHelper.createBannerCreateRequest( - name = "새로운 배너" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("BANNER_CREATED") - } - - @Test - @DisplayName("이름이 중복되면 409를 반환한다") - fun createDuplicateName() { - // given - bannerTestFactory.persistBanner("중복 배너", "https://example.com/dup.jpg") - val request = BannerHelper.createBannerCreateRequest( - name = "중복 배너" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - - @Test - @DisplayName("필수 필드 누락 시 400을 반환한다") - fun createValidationFail() { - // given - val request = mapOf( - "name" to "", - "imageUrl" to "" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - - @Test - @DisplayName("startDate가 endDate보다 이후이면 400을 반환한다") - fun createInvalidDateRange() { - // given - val request = BannerHelper.createBannerCreateRequest( - name = "날짜 오류 배너", - startDate = "2025-12-31T00:00:00", - endDate = "2025-01-01T00:00:00" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - - @Test - @DisplayName("isExternalUrl이 true이고 targetUrl이 없으면 400을 반환한다") - fun createExternalUrlWithoutTarget() { - // given - val request = BannerHelper.createBannerCreateRequest( - name = "외부 URL 오류 배너", - isExternalUrl = true, - targetUrl = null - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - - @Test - @DisplayName("잘못된 HEX 색상 형식이면 400을 반환한다") - fun createInvalidHexColor() { - // given - val request = BannerHelper.createBannerCreateRequest( - name = "색상 오류 배너", - nameFontColor = "invalid" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/banners") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 수정 API") - inner class UpdateBanner { - - @Test - @DisplayName("배너를 수정할 수 있다") - fun updateSuccess() { - // given - val banner = bannerTestFactory.persistBanner("수정 대상 배너", "https://example.com/edit.jpg") - val request = BannerHelper.createBannerUpdateRequest( - name = "수정된 배너" - ) - - // when & then - assertThat( - mockMvcTester.put().uri("/banners/${banner.id}") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("BANNER_UPDATED") - } - - @Test - @DisplayName("존재하지 않는 배너 수정 시 404를 반환한다") - fun updateNotFound() { - // given - val request = BannerHelper.createBannerUpdateRequest() - - // when & then - assertThat( - mockMvcTester.put().uri("/banners/999999") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 삭제 API") - inner class DeleteBanner { - - @Test - @DisplayName("배너를 삭제할 수 있다") - fun deleteSuccess() { - // given - val banner = bannerTestFactory.persistBanner("삭제 대상 배너", "https://example.com/del.jpg") - - // when & then - assertThat( - mockMvcTester.delete().uri("/banners/${banner.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("BANNER_DELETED") - } - - @Test - @DisplayName("존재하지 않는 배너 삭제 시 404를 반환한다") - fun deleteNotFound() { - // when & then - assertThat( - mockMvcTester.delete().uri("/banners/999999") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 활성화 상태 변경 API") - inner class UpdateBannerStatus { - - @Test - @DisplayName("배너 활성화 상태를 변경할 수 있다") - fun updateStatusSuccess() { - // given - val banner = bannerTestFactory.persistBanner("상태 변경 배너", "https://example.com/status.jpg") - val request = BannerHelper.createBannerStatusRequest(isActive = false) - - // when & then - assertThat( - mockMvcTester.patch().uri("/banners/${banner.id}/status") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("BANNER_STATUS_UPDATED") - } - - @Test - @DisplayName("존재하지 않는 배너의 상태 변경 시 404를 반환한다") - fun updateStatusNotFound() { - // given - val request = BannerHelper.createBannerStatusRequest(isActive = false) - - // when & then - assertThat( - mockMvcTester.patch().uri("/banners/999999/status") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("배너 정렬 순서 변경 API") - inner class UpdateBannerSortOrder { - - @Test - @DisplayName("배너 정렬 순서를 변경할 수 있다") - fun updateSortOrderSuccess() { - // given - val banner = bannerTestFactory.persistBanner("정렬 변경 배너", "https://example.com/sort.jpg") - val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) - - // when & then - assertThat( - mockMvcTester.patch().uri("/banners/${banner.id}/sort-order") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("BANNER_SORT_ORDER_UPDATED") - } - } - - @Nested - @DisplayName("인증 테스트") - inner class AuthenticationTest { - - @Test - @DisplayName("인증 없이 요청 시 실패한다") - fun requestWithoutAuth() { - // when & then - assertThat(mockMvcTester.get().uri("/banners")) - .hasStatus4xxClientError() - - assertThat(mockMvcTester.get().uri("/banners/1")) - .hasStatus4xxClientError() - - assertThat( - mockMvcTester.post().uri("/banners") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(BannerHelper.createBannerCreateRequest())) - ) - .hasStatus4xxClientError() - } - } + @Autowired + private lateinit var bannerTestFactory: BannerTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("배너 목록 조회 API") + inner class ListBanners { + @Test + @DisplayName("배너 목록을 조회할 수 있다") + fun listSuccess() { + // given + bannerTestFactory.persistMultipleBanners(3) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("키워드로 필터링하여 조회할 수 있다") + fun listWithKeywordFilter() { + // given + bannerTestFactory.persistBanner("특별 배너", "https://example.com/special.jpg") + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners") + .param("keyword", "특별") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") + fun listWithIsActiveFilter() { + // given + bannerTestFactory.persistMixedActiveBanners(2, 1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners") + .param("isActive", "true") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("배너 유형으로 필터링하여 조회할 수 있다") + fun listWithBannerTypeFilter() { + // given + bannerTestFactory.persistMixedActiveBanners(2, 1) + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners") + .param("bannerType", "CURATION") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("인증 없이 요청하면 401을 반환한다") + fun listUnauthorized() { + assertThat( + mockMvcTester.get().uri("/banners") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 상세 조회 API") + inner class GetBannerDetail { + @Test + @DisplayName("배너 상세 정보를 조회할 수 있다") + fun getDetailSuccess() { + // given + val banner = bannerTestFactory.persistBanner("상세 배너", "https://example.com/detail.jpg") + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("존재하지 않는 배너 조회 시 404를 반환한다") + fun getDetailNotFound() { + // when & then + assertThat( + mockMvcTester + .get() + .uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 생성 API") + inner class CreateBanner { + @Test + @DisplayName("배너를 생성할 수 있다") + fun createSuccess() { + // given + val request = + BannerHelper.createBannerCreateRequest( + name = "새로운 배너" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("BANNER_CREATED") + } + + @Test + @DisplayName("이름이 중복되면 409를 반환한다") + fun createDuplicateName() { + // given + bannerTestFactory.persistBanner("중복 배너", "https://example.com/dup.jpg") + val request = + BannerHelper.createBannerCreateRequest( + name = "중복 배너" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("필수 필드 누락 시 400을 반환한다") + fun createValidationFail() { + // given + val request = + mapOf( + "name" to "", + "imageUrl" to "" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("startDate가 endDate보다 이후이면 400을 반환한다") + fun createInvalidDateRange() { + // given + val request = + BannerHelper.createBannerCreateRequest( + name = "날짜 오류 배너", + startDate = "2025-12-31T00:00:00", + endDate = "2025-01-01T00:00:00" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("isExternalUrl이 true이고 targetUrl이 없으면 400을 반환한다") + fun createExternalUrlWithoutTarget() { + // given + val request = + BannerHelper.createBannerCreateRequest( + name = "외부 URL 오류 배너", + isExternalUrl = true, + targetUrl = null + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + + @Test + @DisplayName("잘못된 HEX 색상 형식이면 400을 반환한다") + fun createInvalidHexColor() { + // given + val request = + BannerHelper.createBannerCreateRequest( + name = "색상 오류 배너", + nameFontColor = "invalid" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/banners") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 수정 API") + inner class UpdateBanner { + @Test + @DisplayName("배너를 수정할 수 있다") + fun updateSuccess() { + // given + val banner = bannerTestFactory.persistBanner("수정 대상 배너", "https://example.com/edit.jpg") + val request = + BannerHelper.createBannerUpdateRequest( + name = "수정된 배너" + ) + + // when & then + assertThat( + mockMvcTester + .put() + .uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("BANNER_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 배너 수정 시 404를 반환한다") + fun updateNotFound() { + // given + val request = BannerHelper.createBannerUpdateRequest() + + // when & then + assertThat( + mockMvcTester + .put() + .uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 삭제 API") + inner class DeleteBanner { + @Test + @DisplayName("배너를 삭제할 수 있다") + fun deleteSuccess() { + // given + val banner = bannerTestFactory.persistBanner("삭제 대상 배너", "https://example.com/del.jpg") + + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/banners/${banner.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("BANNER_DELETED") + } + + @Test + @DisplayName("존재하지 않는 배너 삭제 시 404를 반환한다") + fun deleteNotFound() { + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/banners/999999") + .header("Authorization", "Bearer $accessToken") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 활성화 상태 변경 API") + inner class UpdateBannerStatus { + @Test + @DisplayName("배너 활성화 상태를 변경할 수 있다") + fun updateStatusSuccess() { + // given + val banner = bannerTestFactory.persistBanner("상태 변경 배너", "https://example.com/status.jpg") + val request = BannerHelper.createBannerStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/banners/${banner.id}/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("BANNER_STATUS_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 배너의 상태 변경 시 404를 반환한다") + fun updateStatusNotFound() { + // given + val request = BannerHelper.createBannerStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/banners/999999/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("배너 정렬 순서 변경 API") + inner class UpdateBannerSortOrder { + @Test + @DisplayName("배너 정렬 순서를 변경할 수 있다") + fun updateSortOrderSuccess() { + // given + val banner = bannerTestFactory.persistBanner("정렬 변경 배너", "https://example.com/sort.jpg") + val request = BannerHelper.createBannerSortOrderRequest(sortOrder = 5) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/banners/${banner.id}/sort-order") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("BANNER_SORT_ORDER_UPDATED") + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/banners")) + .hasStatus4xxClientError() + + assertThat(mockMvcTester.get().uri("/banners/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester + .post() + .uri("/banners") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(BannerHelper.createBannerCreateRequest())) + ).hasStatus4xxClientError() + } + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt index e9f2fc378..140fc1d82 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/curation/AdminCurationIntegrationTest.kt @@ -15,421 +15,448 @@ import org.springframework.http.MediaType @Tag("admin_integration") @DisplayName("[integration] Admin Curation API 통합 테스트") class AdminCurationIntegrationTest : IntegrationTestSupport() { - - @Autowired - private lateinit var alcoholTestFactory: AlcoholTestFactory - - private lateinit var accessToken: String - - @BeforeEach - fun setUp() { - val admin = adminUserTestFactory.persistRootAdmin() - accessToken = getAccessToken(admin) - } - - @Nested - @DisplayName("큐레이션 목록 조회 API") - inner class ListCurations { - - @Test - @DisplayName("큐레이션 목록을 조회할 수 있다") - fun listSuccess() { - // given - alcoholTestFactory.persistCurationKeyword() - alcoholTestFactory.persistCurationKeyword() - - // when & then - assertThat( - mockMvcTester.get().uri("/curations") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("키워드로 필터링하여 조회할 수 있다") - fun listWithKeywordFilter() { - // given - alcoholTestFactory.persistCurationKeyword() - - // when & then - assertThat( - mockMvcTester.get().uri("/curations") - .param("keyword", "테스트") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") - fun listWithIsActiveFilter() { - // given - alcoholTestFactory.persistCurationKeyword() - - // when & then - assertThat( - mockMvcTester.get().uri("/curations") - .param("isActive", "true") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("인증 없이 요청하면 401을 반환한다") - fun listUnauthorized() { - assertThat( - mockMvcTester.get().uri("/curations") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 상세 조회 API") - inner class GetCurationDetail { - - @Test - @DisplayName("큐레이션 상세 정보를 조회할 수 있다") - fun getDetailSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - - // when & then - assertThat( - mockMvcTester.get().uri("/curations/${curation.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.success").isEqualTo(true) - } - - @Test - @DisplayName("존재하지 않는 큐레이션 조회 시 404를 반환한다") - fun getDetailNotFound() { - // when & then - assertThat( - mockMvcTester.get().uri("/curations/999999") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 생성 API") - inner class CreateCuration { - - @Test - @DisplayName("큐레이션을 생성할 수 있다") - fun createSuccess() { - // given - val request = CurationHelper.createCurationCreateRequest( - name = "새로운 큐레이션" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/curations") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_CREATED") - } - - @Test - @DisplayName("위스키를 포함하여 큐레이션을 생성할 수 있다") - fun createWithAlcohols() { - // given - val alcohol1 = alcoholTestFactory.persistAlcohol() - val alcohol2 = alcoholTestFactory.persistAlcohol() - - val request = CurationHelper.createCurationCreateRequest( - name = "위스키 큐레이션", - alcoholIds = setOf(alcohol1.id, alcohol2.id) - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/curations") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_CREATED") - } - - @Test - @DisplayName("이름이 중복되면 409를 반환한다") - fun createDuplicateName() { - // given - alcoholTestFactory.persistCurationKeyword() - val request = CurationHelper.createCurationCreateRequest( - name = "테스트 큐레이션" // persistCurationKeyword 기본 이름과 유사 - ) - - // when & then - 실제 중복 체크는 이름이 정확히 일치해야 함 - assertThat( - mockMvcTester.post().uri("/curations") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() // 다른 이름이므로 성공 - } - - @Test - @DisplayName("필수 필드 누락 시 400을 반환한다") - fun createValidationFail() { - // given - val request = mapOf( - "name" to "", // 빈 문자열 - "description" to "설명" - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/curations") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 수정 API") - inner class UpdateCuration { - - @Test - @DisplayName("큐레이션을 수정할 수 있다") - fun updateSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - val request = CurationHelper.createCurationUpdateRequest( - name = "수정된 큐레이션", - description = "수정된 설명" - ) - - // when & then - assertThat( - mockMvcTester.put().uri("/curations/${curation.id}") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_UPDATED") - } - - @Test - @DisplayName("존재하지 않는 큐레이션 수정 시 404를 반환한다") - fun updateNotFound() { - // given - val request = CurationHelper.createCurationUpdateRequest() - - // when & then - assertThat( - mockMvcTester.put().uri("/curations/999999") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 삭제 API") - inner class DeleteCuration { - - @Test - @DisplayName("큐레이션을 삭제할 수 있다") - fun deleteSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - - // when & then - assertThat( - mockMvcTester.delete().uri("/curations/${curation.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_DELETED") - } - - @Test - @DisplayName("존재하지 않는 큐레이션 삭제 시 404를 반환한다") - fun deleteNotFound() { - // when & then - assertThat( - mockMvcTester.delete().uri("/curations/999999") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 활성화 상태 변경 API") - inner class UpdateCurationStatus { - - @Test - @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") - fun updateStatusSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - val request = CurationHelper.createCurationStatusRequest(isActive = false) - - // when & then - assertThat( - mockMvcTester.patch().uri("/curations/${curation.id}/status") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_STATUS_UPDATED") - } - - @Test - @DisplayName("존재하지 않는 큐레이션의 상태 변경 시 404를 반환한다") - fun updateStatusNotFound() { - // given - val request = CurationHelper.createCurationStatusRequest(isActive = false) - - // when & then - assertThat( - mockMvcTester.patch().uri("/curations/999999/status") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("큐레이션 노출 순서 변경 API") - inner class UpdateCurationDisplayOrder { - - @Test - @DisplayName("큐레이션 노출 순서를 변경할 수 있다") - fun updateDisplayOrderSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) - - // when & then - assertThat( - mockMvcTester.patch().uri("/curations/${curation.id}/display-order") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_DISPLAY_ORDER_UPDATED") - } - } - - @Nested - @DisplayName("큐레이션 위스키 관리 API") - inner class ManageCurationAlcohols { - - @Test - @DisplayName("큐레이션에 위스키를 추가할 수 있다") - fun addAlcoholsSuccess() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - val alcohol1 = alcoholTestFactory.persistAlcohol() - val alcohol2 = alcoholTestFactory.persistAlcohol() - val request = CurationHelper.createCurationAlcoholRequest( - alcoholIds = setOf(alcohol1.id, alcohol2.id) - ) - - // when & then - assertThat( - mockMvcTester.post().uri("/curations/${curation.id}/alcohols") - .header("Authorization", "Bearer $accessToken") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_ALCOHOL_ADDED") - } - - @Test - @DisplayName("큐레이션에서 위스키를 제거할 수 있다") - fun removeAlcoholSuccess() { - // given - val alcohol = alcoholTestFactory.persistAlcohol() - val curation = alcoholTestFactory.persistCurationKeyword("테스트 큐레이션", listOf(alcohol)) - - // when & then - assertThat( - mockMvcTester.delete().uri("/curations/${curation.id}/alcohols/${alcohol.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() - .bodyJson() - .extractingPath("$.data.code").isEqualTo("CURATION_ALCOHOL_REMOVED") - } - - @Test - @DisplayName("큐레이션에 포함되지 않은 위스키 제거 시 400을 반환한다") - fun removeAlcoholNotIncluded() { - // given - val curation = alcoholTestFactory.persistCurationKeyword() - val alcohol = alcoholTestFactory.persistAlcohol() - - // when & then - assertThat( - mockMvcTester.delete().uri("/curations/${curation.id}/alcohols/${alcohol.id}") - .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() - } - } - - @Nested - @DisplayName("인증 테스트") - inner class AuthenticationTest { - - @Test - @DisplayName("인증 없이 요청 시 실패한다") - fun requestWithoutAuth() { - // when & then - assertThat(mockMvcTester.get().uri("/curations")) - .hasStatus4xxClientError() - - assertThat(mockMvcTester.get().uri("/curations/1")) - .hasStatus4xxClientError() - - assertThat( - mockMvcTester.post().uri("/curations") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(CurationHelper.createCurationCreateRequest())) - ) - .hasStatus4xxClientError() - } - } + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("큐레이션 목록 조회 API") + inner class ListCurations { + @Test + @DisplayName("큐레이션 목록을 조회할 수 있다") + fun listSuccess() { + // given + alcoholTestFactory.persistCurationKeyword() + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/curations") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("키워드로 필터링하여 조회할 수 있다") + fun listWithKeywordFilter() { + // given + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/curations") + .param("keyword", "테스트") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("활성화 상태로 필터링하여 조회할 수 있다") + fun listWithIsActiveFilter() { + // given + alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/curations") + .param("isActive", "true") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("인증 없이 요청하면 401을 반환한다") + fun listUnauthorized() { + assertThat( + mockMvcTester.get().uri("/curations") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 상세 조회 API") + inner class GetCurationDetail { + @Test + @DisplayName("큐레이션 상세 정보를 조회할 수 있다") + fun getDetailSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("존재하지 않는 큐레이션 조회 시 404를 반환한다") + fun getDetailNotFound() { + // when & then + assertThat( + mockMvcTester + .get() + .uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 생성 API") + inner class CreateCuration { + @Test + @DisplayName("큐레이션을 생성할 수 있다") + fun createSuccess() { + // given + val request = + CurationHelper.createCurationCreateRequest( + name = "새로운 큐레이션" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_CREATED") + } + + @Test + @DisplayName("위스키를 포함하여 큐레이션을 생성할 수 있다") + fun createWithAlcohols() { + // given + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + + val request = + CurationHelper.createCurationCreateRequest( + name = "위스키 큐레이션", + alcoholIds = setOf(alcohol1.id, alcohol2.id) + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_CREATED") + } + + @Test + @DisplayName("이름이 중복되면 409를 반환한다") + fun createDuplicateName() { + // given + alcoholTestFactory.persistCurationKeyword() + val request = + CurationHelper.createCurationCreateRequest( + name = "테스트 큐레이션" // persistCurationKeyword 기본 이름과 유사 + ) + + // when & then - 실제 중복 체크는 이름이 정확히 일치해야 함 + assertThat( + mockMvcTester + .post() + .uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() // 다른 이름이므로 성공 + } + + @Test + @DisplayName("필수 필드 누락 시 400을 반환한다") + fun createValidationFail() { + // given + val request = + mapOf( + "name" to "", // 빈 문자열 + "description" to "설명" + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/curations") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 수정 API") + inner class UpdateCuration { + @Test + @DisplayName("큐레이션을 수정할 수 있다") + fun updateSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = + CurationHelper.createCurationUpdateRequest( + name = "수정된 큐레이션", + description = "수정된 설명" + ) + + // when & then + assertThat( + mockMvcTester + .put() + .uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션 수정 시 404를 반환한다") + fun updateNotFound() { + // given + val request = CurationHelper.createCurationUpdateRequest() + + // when & then + assertThat( + mockMvcTester + .put() + .uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 삭제 API") + inner class DeleteCuration { + @Test + @DisplayName("큐레이션을 삭제할 수 있다") + fun deleteSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/curations/${curation.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_DELETED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션 삭제 시 404를 반환한다") + fun deleteNotFound() { + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/curations/999999") + .header("Authorization", "Bearer $accessToken") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 활성화 상태 변경 API") + inner class UpdateCurationStatus { + @Test + @DisplayName("큐레이션 활성화 상태를 변경할 수 있다") + fun updateStatusSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/curations/${curation.id}/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_STATUS_UPDATED") + } + + @Test + @DisplayName("존재하지 않는 큐레이션의 상태 변경 시 404를 반환한다") + fun updateStatusNotFound() { + // given + val request = CurationHelper.createCurationStatusRequest(isActive = false) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/curations/999999/status") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("큐레이션 노출 순서 변경 API") + inner class UpdateCurationDisplayOrder { + @Test + @DisplayName("큐레이션 노출 순서를 변경할 수 있다") + fun updateDisplayOrderSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val request = CurationHelper.createCurationDisplayOrderRequest(displayOrder = 5) + + // when & then + assertThat( + mockMvcTester + .patch() + .uri("/curations/${curation.id}/display-order") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_DISPLAY_ORDER_UPDATED") + } + } + + @Nested + @DisplayName("큐레이션 위스키 관리 API") + inner class ManageCurationAlcohols { + @Test + @DisplayName("큐레이션에 위스키를 추가할 수 있다") + fun addAlcoholsSuccess() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val alcohol1 = alcoholTestFactory.persistAlcohol() + val alcohol2 = alcoholTestFactory.persistAlcohol() + val request = + CurationHelper.createCurationAlcoholRequest( + alcoholIds = setOf(alcohol1.id, alcohol2.id) + ) + + // when & then + assertThat( + mockMvcTester + .post() + .uri("/curations/${curation.id}/alcohols") + .header("Authorization", "Bearer $accessToken") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_ALCOHOL_ADDED") + } + + @Test + @DisplayName("큐레이션에서 위스키를 제거할 수 있다") + fun removeAlcoholSuccess() { + // given + val alcohol = alcoholTestFactory.persistAlcohol() + val curation = alcoholTestFactory.persistCurationKeyword("테스트 큐레이션", listOf(alcohol)) + + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.code") + .isEqualTo("CURATION_ALCOHOL_REMOVED") + } + + @Test + @DisplayName("큐레이션에 포함되지 않은 위스키 제거 시 400을 반환한다") + fun removeAlcoholNotIncluded() { + // given + val curation = alcoholTestFactory.persistCurationKeyword() + val alcohol = alcoholTestFactory.persistAlcohol() + + // when & then + assertThat( + mockMvcTester + .delete() + .uri("/curations/${curation.id}/alcohols/${alcohol.id}") + .header("Authorization", "Bearer $accessToken") + ).hasStatus4xxClientError() + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + @Test + @DisplayName("인증 없이 요청 시 실패한다") + fun requestWithoutAuth() { + // when & then + assertThat(mockMvcTester.get().uri("/curations")) + .hasStatus4xxClientError() + + assertThat(mockMvcTester.get().uri("/curations/1")) + .hasStatus4xxClientError() + + assertThat( + mockMvcTester + .post() + .uri("/curations") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(CurationHelper.createCurationCreateRequest())) + ).hasStatus4xxClientError() + } + } } diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt index edf19292c..ff65ef61b 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/file/AdminImageUploadIntegrationTest.kt @@ -17,7 +17,6 @@ import java.net.URI @Tag("admin_integration") @DisplayName("[integration] Admin Image Upload API 통합 테스트") class AdminImageUploadIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var amazonS3: AmazonS3 @@ -32,20 +31,21 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { @Nested @DisplayName("PreSigned URL 생성 API") inner class GetPreSignUrlTest { - @Test @DisplayName("PreSigned URL을 생성할 수 있다") fun getPreSignUrl() { // when & then assertThat( - mockMvcTester.get().uri("/s3/presign-url") + mockMvcTester + .get() + .uri("/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "1") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -53,54 +53,67 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { fun getMultiplePreSignUrls() { // when & then assertThat( - mockMvcTester.get().uri("/s3/presign-url") + mockMvcTester + .get() + .uri("/s3/presign-url") .header("Authorization", "Bearer $accessToken") .param("rootPath", "admin/test") .param("uploadSize", "3") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.uploadSize").isEqualTo(3) + .extractingPath("$.data.uploadSize") + .isEqualTo(3) } @Test @DisplayName("응답에 필요한 정보가 포함되어 있다") fun responseContainsRequiredFields() { // when - val result = mockMvcTester.get().uri("/s3/presign-url") - .header("Authorization", "Bearer $accessToken") - .param("rootPath", "admin/test") - .param("uploadSize", "2") - .exchange() + val result = + mockMvcTester + .get() + .uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "2") + .exchange() // then assertThat(result) .hasStatusOk() .bodyJson() - .extractingPath("$.data.bucketName").isNotNull() + .extractingPath("$.data.bucketName") + .isNotNull() assertThat(result) .bodyJson() - .extractingPath("$.data.expiryTime").isEqualTo(5) + .extractingPath("$.data.expiryTime") + .isEqualTo(5) assertThat(result) .bodyJson() - .extractingPath("$.data.imageUploadInfo").isNotNull() + .extractingPath("$.data.imageUploadInfo") + .isNotNull() } @Test @DisplayName("생성된 URL 정보가 올바른 형식이다") fun urlFormatIsCorrect() { // when - val result = mockMvcTester.get().uri("/s3/presign-url") - .header("Authorization", "Bearer $accessToken") - .param("rootPath", "admin/test") - .param("uploadSize", "1") - .exchange() + val result = + mockMvcTester + .get() + .uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/test") + .param("uploadSize", "1") + .exchange() val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") val data = response.data as Map + @Suppress("UNCHECKED_CAST") val imageUploadInfo = data["imageUploadInfo"] as List> @@ -119,31 +132,36 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { fun getPreSignUrlWithoutAuth() { // when & then assertThat( - mockMvcTester.get().uri("/s3/presign-url") + mockMvcTester + .get() + .uri("/s3/presign-url") .param("rootPath", "admin/test") .param("uploadSize", "1") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("PreSigned URL 업로드 시나리오") inner class UploadScenarioTest { - @Test @DisplayName("PreSigned URL로 파일을 업로드하고 S3에서 확인할 수 있다") fun uploadAndVerifyFile() { // given: PreSigned URL 발급 - val result = mockMvcTester.get().uri("/s3/presign-url") - .header("Authorization", "Bearer $accessToken") - .param("rootPath", "admin/upload-test") - .param("uploadSize", "1") - .exchange() + val result = + mockMvcTester + .get() + .uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/upload-test") + .param("uploadSize", "1") + .exchange() val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") val data = response.data as Map + @Suppress("UNCHECKED_CAST") val imageUploadInfo = data["imageUploadInfo"] as List> val uploadUrl = imageUploadInfo[0]["uploadUrl"] as String @@ -179,27 +197,33 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { @DisplayName("여러 파일을 업로드하고 모두 S3에서 확인할 수 있다") fun uploadMultipleFilesAndVerify() { // given: PreSigned URL 3개 발급 - val result = mockMvcTester.get().uri("/s3/presign-url") - .header("Authorization", "Bearer $accessToken") - .param("rootPath", "admin/multi-upload") - .param("uploadSize", "3") - .exchange() + val result = + mockMvcTester + .get() + .uri("/s3/presign-url") + .header("Authorization", "Bearer $accessToken") + .param("rootPath", "admin/multi-upload") + .param("uploadSize", "3") + .exchange() val response = parseResponse(result) + @Suppress("UNCHECKED_CAST") val data = response.data as Map + @Suppress("UNCHECKED_CAST") val imageUploadInfo = data["imageUploadInfo"] as List> // when: 3개 파일 모두 업로드 - val uploadResults = imageUploadInfo.mapIndexed { index, info -> - val uploadUrl = info["uploadUrl"] as String - val viewUrl = info["viewUrl"] as String - val s3Key = viewUrl.substringAfter("fake-cloudfront.net/") - val content = "content-$index" - val responseCode = uploadToPreSignedUrl(uploadUrl, content) - Triple(s3Key, content, responseCode) - } + val uploadResults = + imageUploadInfo.mapIndexed { index, info -> + val uploadUrl = info["uploadUrl"] as String + val viewUrl = info["viewUrl"] as String + val s3Key = viewUrl.substringAfter("fake-cloudfront.net/") + val content = "content-$index" + val responseCode = uploadToPreSignedUrl(uploadUrl, content) + Triple(s3Key, content, responseCode) + } // then: 모든 업로드 성공 확인 val bucketName = TestContainersConfig.getTestBucket() @@ -207,15 +231,22 @@ class AdminImageUploadIntegrationTest : IntegrationTestSupport() { assertThat(responseCode).isEqualTo(200) assertThat(amazonS3.doesObjectExist(bucketName, s3Key)).isEqualTo(true) - val actualContent = amazonS3.getObject(bucketName, s3Key) - .objectContent.bufferedReader().use { it.readText() } + val actualContent = + amazonS3 + .getObject(bucketName, s3Key) + .objectContent + .bufferedReader() + .use { it.readText() } assertThat(actualContent).isEqualTo(expectedContent) } log.info("3개 파일 업로드 및 검증 완료") } - private fun uploadToPreSignedUrl(preSignedUrl: String, content: String): Int { + private fun uploadToPreSignedUrl( + preSignedUrl: String, + content: String + ): Int { val url = URI(preSignedUrl).toURL() val connection = url.openConnection() as HttpURLConnection return try { diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt index 53f6b34d1..1c1a56c8f 100644 --- a/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/help/AdminHelpIntegrationTest.kt @@ -17,7 +17,6 @@ import org.springframework.http.MediaType @Tag("admin_integration") @DisplayName("[integration] Admin Help API 통합 테스트") class AdminHelpIntegrationTest : IntegrationTestSupport() { - @Autowired private lateinit var helpTestFactory: HelpTestFactory @@ -35,7 +34,6 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { @Nested @DisplayName("문의 목록 조회 API") inner class GetHelpListTest { - @Test @DisplayName("문의 목록을 조회할 수 있다") fun getHelpList() { @@ -45,12 +43,14 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/helps") + mockMvcTester + .get() + .uri("/helps") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -62,13 +62,15 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/helps") + mockMvcTester + .get() + .uri("/helps") .header("Authorization", "Bearer $accessToken") .param("status", StatusType.WAITING.name) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -81,13 +83,15 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/helps") + mockMvcTester + .get() + .uri("/helps") .header("Authorization", "Bearer $accessToken") .param("type", HelpType.WHISKEY.name) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) } @Test @@ -96,15 +100,13 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { // when & then assertThat( mockMvcTester.get().uri("/helps") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } @Nested @DisplayName("문의 상세 조회 API") inner class GetHelpDetailTest { - @Test @DisplayName("문의 상세를 조회할 수 있다") fun getHelpDetail() { @@ -114,20 +116,24 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { // when & then assertThat( - mockMvcTester.get().uri("/helps/${help.id}") + mockMvcTester + .get() + .uri("/helps/${help.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) assertThat( - mockMvcTester.get().uri("/helps/${help.id}") + mockMvcTester + .get() + .uri("/helps/${help.id}") .header("Authorization", "Bearer $accessToken") - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.title").isEqualTo("테스트 제목") + .extractingPath("$.data.title") + .isEqualTo("테스트 제목") } @Test @@ -135,19 +141,20 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { fun getHelpDetailNotFound() { // when & then assertThat( - mockMvcTester.get().uri("/helps/99999") + mockMvcTester + .get() + .uri("/helps/99999") .header("Authorization", "Bearer $accessToken") - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } } @Nested @DisplayName("문의 답변 등록 API") inner class AnswerHelpTest { - @Test @DisplayName("문의에 답변을 등록할 수 있다") fun answerHelp() { @@ -155,31 +162,36 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { val user = userTestFactory.persistUser() val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) - val request = mapOf( - "responseContent" to "답변 내용입니다.", - "status" to StatusType.SUCCESS.name - ) + val request = + mapOf( + "responseContent" to "답변 내용입니다.", + "status" to StatusType.SUCCESS.name + ) // when & then assertThat( - mockMvcTester.post().uri("/helps/${help.id}/answer") + mockMvcTester + .post() + .uri("/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.success").isEqualTo(true) + .extractingPath("$.success") + .isEqualTo(true) assertThat( - mockMvcTester.post().uri("/helps/${help.id}/answer") + mockMvcTester + .post() + .uri("/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.status").isEqualTo(StatusType.SUCCESS.name) + .extractingPath("$.data.status") + .isEqualTo(StatusType.SUCCESS.name) } @Test @@ -189,42 +201,48 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { val user = userTestFactory.persistUser() val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) - val request = mapOf( - "responseContent" to "반려 사유입니다.", - "status" to StatusType.REJECT.name - ) + val request = + mapOf( + "responseContent" to "반려 사유입니다.", + "status" to StatusType.REJECT.name + ) // when & then assertThat( - mockMvcTester.post().uri("/helps/${help.id}/answer") + mockMvcTester + .post() + .uri("/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatusOk() + ).hasStatusOk() .bodyJson() - .extractingPath("$.data.status").isEqualTo(StatusType.REJECT.name) + .extractingPath("$.data.status") + .isEqualTo(StatusType.REJECT.name) } @Test @DisplayName("존재하지 않는 문의에 답변하면 실패한다") fun answerHelpNotFound() { // given - val request = mapOf( - "responseContent" to "답변 내용입니다.", - "status" to StatusType.SUCCESS.name - ) + val request = + mapOf( + "responseContent" to "답변 내용입니다.", + "status" to StatusType.SUCCESS.name + ) // when & then assertThat( - mockMvcTester.post().uri("/helps/99999/answer") + mockMvcTester + .post() + .uri("/helps/99999/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() .bodyJson() - .extractingPath("$.success").isEqualTo(false) + .extractingPath("$.success") + .isEqualTo(false) } @Test @@ -234,19 +252,21 @@ class AdminHelpIntegrationTest : IntegrationTestSupport() { val user = userTestFactory.persistUser() val help = helpTestFactory.persistHelp(user.id, HelpType.WHISKEY) - val request = mapOf( - "responseContent" to "", - "status" to StatusType.SUCCESS.name - ) + val request = + mapOf( + "responseContent" to "", + "status" to StatusType.SUCCESS.name + ) // when & then assertThat( - mockMvcTester.post().uri("/helps/${help.id}/answer") + mockMvcTester + .post() + .uri("/helps/${help.id}/answer") .header("Authorization", "Bearer $accessToken") .contentType(MediaType.APPLICATION_JSON) .content(mapper.writeValueAsString(request)) - ) - .hasStatus4xxClientError() + ).hasStatus4xxClientError() } } } From 4df06607f3a037ddbe170d9c8269565cd4a21988 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 6 Apr 2026 13:48:21 +0900 Subject: [PATCH 19/31] =?UTF-8?q?refactor:=20Claude=20=EC=8A=A4=ED=82=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=EC=95=88?= =?UTF-8?q?=EC=9D=84=20=EB=AC=B8=EC=84=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../operation/utils/DataInitializer.java | 110 +++-- git.environment-variables | 2 +- plan/claude-ai-harness-improvement.md | 388 ++++++++++++++++++ 3 files changed, 442 insertions(+), 58 deletions(-) create mode 100644 plan/claude-ai-harness-improvement.md diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java index 8ecd74f97..919ce3ad2 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java @@ -17,70 +17,66 @@ @Component @SuppressWarnings("unchecked") public class DataInitializer { - private static final String OFF_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = false"; - private static final String ON_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = true"; - private static final String TRUNCATE_SQL_FORMAT = "TRUNCATE %s"; - private static final List truncationDMLs = new ArrayList<>(); + private static final String OFF_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = false"; + private static final String ON_FOREIGN_CONSTRAINTS = "SET foreign_key_checks = true"; + private static final String TRUNCATE_SQL_FORMAT = "TRUNCATE %s"; + private static final List truncationDMLs = new ArrayList<>(); - private static volatile boolean initialized = false; + private static volatile boolean initialized = false; - private static final Set SYSTEM_TABLE_PREFIXES = - Set.of("flyway_", "databasechangelog", "schema_version"); + private static final Set SYSTEM_TABLE_PREFIXES = + Set.of("flyway_", "databasechangelog", "schema_version"); - @PersistenceContext - private EntityManager em; + @PersistenceContext private EntityManager em; - protected DataInitializer() { - } + protected DataInitializer() {} - @Transactional(value = REQUIRES_NEW) - public void deleteAll() { - if (!initialized) { - initCache(); - } - log.debug("데이터 초기화 시작"); - em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); - truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); - em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - } + @Transactional(value = REQUIRES_NEW) + public void deleteAll() { + if (!initialized) { + initCache(); + } + log.debug("데이터 초기화 시작"); + em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); + truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); + em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); + } - /** - * 캐시를 강제로 재초기화 후 전체 데이터 삭제 (테스트에서 동적 테이블 생성 시 사용) - */ - @Transactional(value = REQUIRES_NEW) - public void refreshCache() { - log.debug("데이터 초기화 시작 (캐시 재초기화)"); - synchronized (truncationDMLs) { - truncationDMLs.clear(); - init(); - initialized = true; - } - em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); - truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); - em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); - log.debug("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); - } + /** 캐시를 강제로 재초기화 후 전체 데이터 삭제 (테스트에서 동적 테이블 생성 시 사용) */ + @Transactional(value = REQUIRES_NEW) + public void refreshCache() { + log.debug("데이터 초기화 시작 (캐시 재초기화)"); + synchronized (truncationDMLs) { + truncationDMLs.clear(); + init(); + initialized = true; + } + em.createNativeQuery(OFF_FOREIGN_CONSTRAINTS).executeUpdate(); + truncationDMLs.stream().map(em::createNativeQuery).forEach(Query::executeUpdate); + em.createNativeQuery(ON_FOREIGN_CONSTRAINTS).executeUpdate(); + log.debug("데이터 초기화 완료 - {}개 테이블 처리됨", truncationDMLs.size()); + } - private void initCache() { - if (!initialized) { - synchronized (truncationDMLs) { - if (!initialized) { - init(); - initialized = true; - } - } - } - } + private void initCache() { + if (!initialized) { + synchronized (truncationDMLs) { + if (!initialized) { + init(); + initialized = true; + } + } + } + } - private void init() { - final List tableNames = em.createNativeQuery("SHOW TABLES ").getResultList(); - tableNames.stream() - .filter(tableName -> !isSystemTable((String) tableName)) - .map(tableName -> String.format(TRUNCATE_SQL_FORMAT, tableName)) - .forEach(truncationDMLs::add); - } + private void init() { + final List tableNames = em.createNativeQuery("SHOW TABLES ").getResultList(); + tableNames.stream() + .filter(tableName -> !isSystemTable((String) tableName)) + .map(tableName -> String.format(TRUNCATE_SQL_FORMAT, tableName)) + .forEach(truncationDMLs::add); + } - private boolean isSystemTable(String tableName) { - return SYSTEM_TABLE_PREFIXES.stream().anyMatch(prefix -> tableName.startsWith(prefix)); - } + private boolean isSystemTable(String tableName) { + return SYSTEM_TABLE_PREFIXES.stream().anyMatch(prefix -> tableName.startsWith(prefix)); + } } diff --git a/git.environment-variables b/git.environment-variables index 57cd5c5bb..c31d51c60 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit 57cd5c5bbeb473be1c2b1036e6ea718a2f10227b +Subproject commit c31d51c60a7fbad98fa0053d82af5b1fa0ee02da diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md new file mode 100644 index 000000000..f6147b6f2 --- /dev/null +++ b/plan/claude-ai-harness-improvement.md @@ -0,0 +1,388 @@ +# Claude AI 하드 하네스 구조 개선안 + +> CLAUDE.md 중심 구조에서 Skills + Hooks 기반 분산 구조로 전환 +--- + +## 1. 현재 구조 분석 + +### 현재 파일 구성 + +``` +.claude/ +├── settings.json # 훅: SessionStart (Docker 세팅)만 존재 +├── hooks/ +│ └── session-start.sh # 원격 환경 Docker 설치 전용 +├── docs/ +│ └── ADMIN-API-GUIDE.md # 210줄, 스킬/훅 미연결 (사실상 사문서) +└── skills/ + └── deploy-batch/ # 배포 스킬 1개만 존재 + ├── SKILL.md + └── scripts/*.sh + +CLAUDE.md # ~210줄, 모든 규칙이 여기에 집중 +``` + +### 문제점 + +| 문제 | 영향 | +|------|------| +| CLAUDE.md에 개요 + 구현 규칙 + 테스트 규칙 + 코드 스타일 전부 포함 | 컨텍스트 윈도우 낭비, 모든 대화에 전부 로딩됨 | +| ADMIN-API-GUIDE.md가 스킬로 연결되지 않음 | 수동으로 읽어야만 참조 가능 | +| 포매팅(spotless) 자동화 없음 | Claude가 수정한 코드가 포맷 위반 상태로 남음 | +| 커밋 메시지 검증 없음 | 규칙(타입: 제목) 위반 가능 | +| 구현 워크플로우가 코드화되지 않음 | 매번 대화로 가이드를 재설명해야 함 | + +### 참고: 프론트엔드 프로젝트 (이미 스킬 구조 적용) + +``` +bottle-note-frontend/.claude/skills/Code/ +├── SKILL.md # 메인 스킬 +└── Workflows/ + ├── api.md # /api - API 함수 + Query 훅 생성 + ├── component.md # /component + ├── fix.md # /fix + ├── hook.md # /hook + ├── page.md # /page + ├── refactor.md # /refactor + ├── store.md # /store + └── test.md # /test +``` + +--- + +## 2. 목표 구조 + +``` +CLAUDE.md # 경량화: 개요 + 모듈 구조 + 빌드 명령 + 핵심 원칙만 + +.claude/ +├── settings.json # 훅 설정 (포매팅, 커밋 검증 등) +├── hooks/ +│ ├── session-start.sh # [기존] 원격 환경 Docker +│ ├── post-edit-format.sh # [신규] Edit/Write 후 spotless 실행 +│ └── pre-commit-validate.sh # [신규] 커밋 메시지 규칙 검증 +│ +├── skills/ +│ ├── deploy-batch/ # [기존] 배포 스킬 +│ │ +│ ├── admin-api/ # [신규] /admin-api 스킬 +│ │ ├── SKILL.md # 진입점, Phase 분기 +│ │ └── references/ +│ │ ├── checklist.md # 구현 체크리스트 +│ │ ├── controller.md # 컨트롤러 규칙 +│ │ ├── service.md # 서비스 규칙 +│ │ └── test.md # 테스트 규칙 +│ │ +│ ├── product-api/ # [신규] /product-api 스킬 +│ │ ├── SKILL.md +│ │ └── references/ +│ │ ├── checklist.md +│ │ └── controller.md +│ │ +│ ├── test/ # [신규] /test 스킬 +│ │ ├── SKILL.md +│ │ └── references/ +│ │ ├── unit-test.md # 단위 테스트 (Fake/Stub 패턴) +│ │ ├── integration-test.md # 통합 테스트 (TestContainers) +│ │ └── restdocs-test.md # RestDocs API 문서화 테스트 +│ │ +│ └── domain/ # [신규] /domain 스킬 +│ ├── SKILL.md +│ └── references/ +│ ├── entity.md # 엔티티 작성 규칙 +│ ├── repository.md # 3계층 레포지토리 패턴 +│ └── event.md # 이벤트 기반 아키텍처 +│ +└── docs/ + └── ADMIN-API-GUIDE.md # admin-api 스킬 references로 흡수 후 제거 +``` + +--- + +## 3. CLAUDE.md 경량화 방안 + +### 유지할 내용 (CLAUDE.md에 남길 것) + +| 섹션 | 이유 | +|------|------| +| 프로젝트 개요 (기술 스택, 아키텍처) | 모든 대화에서 필요한 기본 컨텍스트 | +| 모듈 구조 다이어그램 | 모듈 간 의존성 파악에 필수 | +| 빌드 및 실행 명령어 | 빈번히 참조됨 | +| 핵심 원칙 요약 (5줄 이내) | DDD, 계층 구조, 네이밍 컨벤션 키워드만 | + +### 스킬로 이동할 내용 + +| 현재 CLAUDE.md 섹션 | 이동 대상 스킬 | +|---------------------|---------------| +| Admin API 구현 규칙 (~50줄) | `/admin-api` 스킬 | +| 코드 작성 규칙 - 아키텍처 패턴, 네이밍, 예외 처리 | `/domain` 스킬 | +| 테스트 작성 규칙 (~30줄) | `/test` 스킬 | +| 데이터베이스 설계 - 레포지토리 계층 구조 (~60줄) | `/domain` 스킬 references/repository.md | +| 보안 및 인증, 외부 서비스 연동 | `/product-api`, `/admin-api` 스킬에서 필요 시 참조 | + +### 예상 결과 + +- **현재**: CLAUDE.md ~210줄 (매 대화마다 전체 로딩) +- **목표**: CLAUDE.md ~60줄 (개요만) + 스킬별 on-demand 로딩 + +--- + +## 4. 스킬 설계 + +### 4.1 `/admin-api` 스킬 + +```yaml +# .claude/skills/admin-api/SKILL.md 프론트매터 +--- +name: admin-api +description: | + Admin API 구현 가이드. "어드민 API", "admin api", "관리자 API" 요청 시 사용. + mono 모듈 서비스 작성 -> admin-api 컨트롤러 -> 테스트 순서로 안내. +argument-hint: "[도메인명] [작업유형: crud|search|action]" +allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent +--- +``` + +**트리거 예시**: +- `/admin-api banner crud` - 배너 CRUD 구현 +- `/admin-api curation search` - 큐레이션 검색 API 구현 + +**동작**: +1. `$ARGUMENTS[0]`(도메인)으로 기존 코드 탐색 (mono 모듈 내 해당 도메인) +2. `$ARGUMENTS[1]`(작업유형)에 따라 체크리스트 분기 +3. 단계별 구현 가이드 제공 (Phase 1: mono, Phase 2: admin-api, Phase 3: test) + +**핵심**: 기존 `.claude/docs/ADMIN-API-GUIDE.md`의 내용을 references/로 분산 흡수 + +### 4.2 `/product-api` 스킬 + +```yaml +--- +name: product-api +description: | + Product API 구현 가이드. "API 추가", "엔드포인트 구현" 요청 시 사용. + mono 모듈 Facade/Service -> product-api 컨트롤러 -> 테스트 순서로 안내. +argument-hint: "[도메인명] [작업유형]" +allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent +--- +``` + +**동작**: +1. 도메인 탐색 (mono 모듈) +2. Facade -> Service -> Controller 순서로 구현 가이드 +3. 커서 페이징 패턴 적용 (Admin과 다름) + +### 4.3 `/test` 스킬 + +```yaml +--- +name: test +description: | + 테스트 코드 작성 가이드. "테스트 작성", "test" 요청 시 사용. + 단위/통합/RestDocs 테스트를 구분하여 안내. +argument-hint: "[대상클래스 또는 도메인] [unit|integration|restdocs]" +allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent +--- +``` + +**트리거 예시**: +- `/test ReviewService unit` - 리뷰 서비스 단위 테스트 +- `/test AdminBannerController restdocs` - 배너 API 문서화 테스트 +- `/test AlcoholService integration` - 주류 서비스 통합 테스트 + +**동작 분기**: +- `unit`: Fake/Stub 패턴 안내, InMemory 레포지토리 사용 +- `integration`: IntegrationTestSupport 상속, TestContainers, Awaitility +- `restdocs`: @WebMvcTest, MockitoBean, document() 스니펫 생성 + +### 4.4 `/domain` 스킬 + +```yaml +--- +name: domain +description: | + 도메인 모델 구현 가이드. "엔티티 추가", "도메인 설계", "레포지토리" 요청 시 사용. + 엔티티/레포지토리 3계층/이벤트 패턴을 안내. +argument-hint: "[도메인명] [entity|repository|event]" +allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent +--- +``` + +**동작 분기**: +- `entity`: BaseEntity 상속, @Embeddable 복합키, Hibernate @Filter +- `repository`: 3계층 (DomainRepository -> JpaRepository -> QueryDSL Custom) +- `event`: ApplicationEventPublisher, @TransactionalEventListener, @Async + +--- + +## 5. 훅 설계 + +### 5.1 PostToolUse: 자동 포매팅 + +**목적**: Claude가 Java 파일을 수정하면 자동으로 spotless 적용 + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-format.sh", + "timeout": 30, + "statusMessage": "spotless 포매팅 적용 중..." + } + ] + } + ] + } +} +``` + +**post-edit-format.sh 동작**: +1. stdin으로 전달된 JSON에서 `tool_input.file_path` 추출 +2. `.java` 파일인 경우에만 spotless 실행 +3. mono 또는 product-api 모듈의 파일인지 판별 후 해당 모듈에 spotlessApply +4. `.kt` 파일은 별도 포매터 적용 (ktlint 등, 추후 검토) + +**고려사항**: +- spotlessApply는 프로젝트 전체를 포매팅하므로 성능 이슈 가능 +- 대안: google-java-format CLI를 직접 호출하여 단일 파일만 포매팅 +- 타임아웃 30초 설정 (빌드 캐시가 있으면 빠름) + +### 5.2 PreToolUse: 커밋 메시지 검증 + +**목적**: 커밋 메시지가 `타입: 제목` 형식을 따르는지 검증 + +```json +{ + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-validate.sh", + "timeout": 5 + } + ] + } + ] +} +``` + +**pre-commit-validate.sh 동작**: +1. stdin JSON에서 `tool_input.command` 추출 +2. `git commit` 명령인지 확인 (아니면 즉시 통과) +3. 커밋 메시지 추출 후 정규식 검증: `^(feat|fix|refactor|test|docs|chore): .{1,50}$` +4. 실패 시 exit 2 (BLOCK) + stderr로 피드백 메시지 출력 + +### 5.3 훅 설정 종합 (settings.json 최종 형태) + +```json +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-format.sh", + "timeout": 30, + "statusMessage": "spotless 포매팅 적용 중..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-validate.sh", + "timeout": 5 + } + ] + } + ] + } +} +``` + +--- + +## 6. 구현 로드맵 + +### Phase 1: CLAUDE.md 경량화 + +- [ ] CLAUDE.md에서 스킬로 이동할 섹션 식별 및 분리 +- [ ] 경량화된 CLAUDE.md 작성 (~60줄) +- [ ] 기존 내용 백업 + +### Phase 2: 스킬 구현 + +- [ ] `/admin-api` 스킬 (기존 ADMIN-API-GUIDE.md 흡수) +- [ ] `/test` 스킬 +- [ ] `/product-api` 스킬 +- [ ] `/domain` 스킬 +- [ ] 각 스킬 테스트 (실제 트리거 및 동작 확인) + +### Phase 3: 훅 구현 + +- [ ] `post-edit-format.sh` 작성 및 테스트 +- [ ] `pre-commit-validate.sh` 작성 및 테스트 +- [ ] settings.json 업데이트 +- [ ] 훅 성능 측정 (spotless 소요 시간) + +### Phase 4: 검증 및 조정 + +- [ ] 실제 작업 시나리오 테스트 (Admin API 1개 구현해보기) +- [ ] 컨텍스트 윈도우 사용량 비교 (전/후) +- [ ] 스킬 트리거 정확도 확인 +- [ ] 훅 오탐/미탐 확인 + +--- + +## 7. 의사결정 필요 사항 + +| # | 질문 | 선택지 | 메모 | +|---|------|--------|------| +| 1 | spotless를 PostToolUse에서 실행할지, 커밋 전에만 실행할지 | A: 매 편집마다 / B: 커밋 전만 | A는 정확하지만 느림, B는 빠르지만 중간 상태가 어색 | +| 2 | 스킬 간 공통 규칙(네이밍, 예외 처리)을 어디에 둘지 | A: CLAUDE.md / B: 별도 공통 스킬 / C: 각 스킬에 중복 | A가 가장 단순 | +| 3 | admin-api 스킬에 context: fork를 적용할지 | A: fork (독립 컨텍스트) / B: 기본 (대화 공유) | fork면 대화 이력 참조 불가 | +| 4 | Kotlin 파일 포매팅은 어떻게 할지 | A: ktlint / B: ktfmt / C: 당분간 제외 | admin-api는 Kotlin | + +--- + +## 8. 기대 효과 + +| 지표 | 현재 | 목표 | +|------|------|------| +| CLAUDE.md 크기 | ~210줄 (항상 로딩) | ~60줄 (개요만) | +| 구현 가이드 접근 | 수동 (대화로 설명) | `/admin-api banner crud` 한 줄 | +| 코드 포매팅 | 수동 spotlessApply | 자동 (훅) | +| 커밋 메시지 검증 | 없음 | 자동 (훅) | +| 새 기능 구현 시간 | 매번 규칙 재설명 필요 | 스킬이 컨텍스트 제공 | + +--- + +## 참고 자료 + +- Claude Code Hooks 공식 문서: https://code.claude.com/docs/en/hooks +- Claude Code Skills 공식 문서: https://code.claude.com/docs/en/skills +- 프론트엔드 스킬 구조: `bottle-note-frontend/.claude/skills/Code/` +- 기존 배포 스킬: `.claude/skills/deploy-batch/` From 6647ffd2c62ea5473f59500f578070119db7dd60 Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 6 Apr 2026 16:01:34 +0900 Subject: [PATCH 20/31] =?UTF-8?q?refactor:=20CLAUDE.md=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=98=81=EC=96=B4=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=B4=EC=9A=A9=20=EA=B0=84=EC=86=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 153 +++++++----------- .../operation/utils/TestContainersConfig.java | 10 +- build.gradle | 2 + plan/claude-ai-harness-improvement.md | 69 ++++---- 4 files changed, 102 insertions(+), 132 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5c0cb33a4..6ea5dc7ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,118 +2,75 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -> 해당 지침은 Claude Code가 이 저장소의 코드 작업 시 참고할 수 있도록 작성되었습니다. -> 만약 해당 파일과 동일한 경로에 CLAUDE.personal.md 파일이 존재한다면, 그 파일의 내용 또한 참고하여 작업을 진행하세요. -> **개인 지침이 팀 지침과 충돌할 경우 팀 지침을 우선시하세요.** - -## 유저 지침 - -- 영어로 질문하는 경우 의도를 잘 파악하고 문법적 오류가 있을 경우 응답에 문법에 대한 피드백을 제공하세요. -- 질문 언어와 관련없이 응답 언어는 한국어로 작성하세요. -- 복잡하거나 애매한 질문의 경우 이해한 내용을 요약해서 응답에 포함하세요. -- 질문에 대한 답변은 명확하고 간결하게 작성하세요 (3-5줄 내외 권장). -- 코드 예시가 필요한 경우 전체 코드를 한번에 보여줄지, 단계별로 나누어 설명할지 사용자에게 물어보세요. -- 코드 예시가 필요한 경우, 코드의 목적과 사용법을 간단히 설명하세요. -- 코드를 작성할 경우 주석은 한줄로 간략하게만 작성하세요. -- 기존 코드 수정 시 프로젝트의 기존 패턴과 컨벤션을 반드시 따르세요. -- 여러 파일 수정이 필요한 경우 수정할 파일 목록을 미리 알려주세요. -- 에러나 문제 해결 시 단계별 접근 방법을 제시하세요. -- 여러 해결책이 있을 때는 프로젝트 컨텍스트에 가장 적합한 방법을 우선 추천하세요. - -## 프로젝트 개요 - -- **기술 스택**: Spring Boot 3.1.9, Java 21, MySQL, Redis, QueryDSL -- **아키텍처**: 도메인 주도 설계(DDD) 기반 멀티모듈 구조 -- **주요 도메인**: alcohols, user, review, rating, support, history, picks, like +> If a `CLAUDE.personal.md` file exists in the same directory, also refer to its contents. +> **Team instructions take priority over personal instructions when they conflict.** + +## User Instructions + +- Always respond in Korean regardless of question language. +- If the user asks in English and there are grammar errors, provide grammar feedback in the response. +- For complex or ambiguous questions, include a summary of your understanding in the response. +- Keep answers clear and concise (3-5 lines recommended). +- When code examples are needed, ask whether to show all at once or step by step. +- When writing code, keep comments to a single brief line. +- When modifying existing code, always follow the project's existing patterns and conventions. +- When multiple files need changes, list the files to be modified upfront. +- For error resolution, present a step-by-step approach. +- When multiple solutions exist, recommend the one most fitting the project context first. + +## Project Overview + +- **Stack**: Spring Boot 3.1.9, Java 21, MySQL, Redis, QueryDSL +- **Architecture**: DDD-based multi-module structure +- **Core Domains**: alcohols, user, review, rating, support, history, picks, like ## 모듈 구조 ```mermaid flowchart TD - subgraph deploy["배포 단위 (JAR)"] + subgraph executable["Executable (bootJar)"] direction LR - product["product-api
클라이언트용 · Java"] - admin["admin-api
관리자용 · Kotlin"] + product["product-api
Client API · Java"] + admin["admin-api
Admin API · Kotlin"] + batch["batch
Batch · Quartz"] end - subgraph libs["라이브러리 모듈"] + subgraph library["Library (JAR)"] direction LR - obs["observability
모니터링 · OpenTelemetry"] - batch["batch
배치 작업 · Quartz"] + mono["mono
Shared Domain · JPA · QueryDSL"] + obs["observability
OpenTelemetry · Micrometer"] end - mono["bottlenote-mono
공유 라이브러리
도메인/비즈니스 · JPA · QueryDSL"] - - product --> obs & batch & mono - admin --> obs & mono + product --> mono & obs + admin --> mono batch --> mono + mono --> obs ``` -### bottlenote-mono -- **역할**: 레거시 모놀리식 모듈 (핵심 비즈니스 로직) -- **특징**: - - 모든 도메인 엔티티와 비즈니스 로직 포함 - - JPA, QueryDSL, Redis 등 데이터 접근 계층 - - 보안, 인증, 외부 서비스 연동 로직 - - 라이브러리 JAR로 빌드 (실행 불가) - -### bottlenote-product-api -- **역할**: API 서버 모듈 -- **특징**: - - bottlenote-mono 모듈 의존 - - REST API 컨트롤러 계층 - - API 문서화 (REST Docs, OpenAPI) - - 실행 가능한 Spring Boot JAR로 빌드 - - 테스트 환경 구성 (단위, 통합, 아키텍처 규칙) - - 클라이언트 사용자들의 요구사항을 처리하는 api 서버 - -### bottlenote-admin-api -- **역할**: 관리자용 API 서버 모듈 (Kotlin) -- **특징**: - - bottlenote-mono 모듈 의존 (비즈니스 로직은 mono에서 제공) - - 관리자 전용 REST API (프레젠테이션 계층만 담당) - - context-path: `/admin/api/v1` - - Spring REST Docs 기반 API 문서화 - - JWT 기반 어드민 전용 인증 체계 - - 루트 어드민 자동 초기화 (`ApplicationReadyEvent`) - -**패키지 구조**: -``` -app/ -├── Application.kt # 진입점 -├── bottlenote/{domain}/ -│ ├── presentation/ # 컨트롤러 -│ └── config/ # 설정 클래스 -└── global/ - ├── common/ # 공통 유틸 - └── security/ # 보안 설정 -``` - -**Kotlin 코딩 컨벤션**: -- `data class`: 요청/응답 DTO, 설정 클래스 -- `object`: 테스트 헬퍼 (싱글톤) -- `val` 불변성 선호, `lateinit var`는 DI 주입용으로만 사용 -- 생성자 주입: 클래스 선언부에 파라미터로 명시 -- Named parameters 활용으로 가독성 향상 - -**테스트 구조**: -- `@Tag("admin_integration")`: 통합 테스트 태그 -- `IntegrationTestSupport`: 테스트 베이스 클래스 (TestContainers, MockMvcTester) -- `app/docs/`: RestDocs 테스트 (`@WebMvcTest`) -- `app/integration/`: 통합 테스트 -- `app/helper/`: 테스트 헬퍼 (`object` 싱글톤) - -**인증 체계**: -- `AdminJwtAuthenticationFilter`, `AdminJwtAuthenticationManager` 사용 -- `SecurityContextUtil.getAdminUserIdByContext()`: 현재 어드민 ID 조회 -- RBAC 역할: `ROOT_ADMIN`, `PARTNER`, `COMMUNITY_MANAGER` - -### bottlenote-batch -- **역할**: 배치 처리 모듈 -- **특징**: - - Spring Batch 기반 배치 작업 - - Quartz 스케줄러 통합 - - 정기적인 데이터 처리 작업 +### bottlenote-mono (Library) +- Shared domain library: all entities, business logic, JPA, QueryDSL, Redis, Caffeine cache +- Security, authentication, external service integration (AWS S3, Firebase, Feign) +- Depends on `observability` (api import) + +### bottlenote-product-api (Executable, Java) +- Client-facing REST API server (picks, likes, ratings, auth, follow, etc.) +- Depends on `mono` + `observability` +- REST Docs + OpenAPI documentation + +### bottlenote-admin-api (Executable, Kotlin) +- Admin REST API (presentation layer only, business logic from mono) +- Depends on `mono` only +- context-path: `/admin/api/v1` +- JWT-based admin authentication, RBAC: `ROOT_ADMIN`, `PARTNER`, `COMMUNITY_MANAGER` + +### bottlenote-batch (Executable, Java) +- Spring Batch + Quartz scheduler for ranking/popularity calculation jobs +- Depends on `mono` only + +### bottlenote-observability (Library) +- Standalone monitoring library: OpenTelemetry, Micrometer, Spring Actuator +- AOP-based tracing: `TracingService`, `@TraceMethod`, `@SkipTrace` +- No internal module dependencies ## 빌드 및 실행 diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index c10a47d34..31de8da3b 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -8,8 +8,6 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.redis.testcontainers.RedisContainer; -import java.util.Collections; -import java.util.UUID; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; @@ -18,6 +16,9 @@ import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; +import java.util.Collections; +import java.util.UUID; + /** * TestContainers 설정을 관리하는 Spring Bean 기반 Configuration * @@ -27,13 +28,16 @@ @SuppressWarnings("resource") public class TestContainersConfig { + private static final String DEFAULT_DB_NAME = "bottlenote"; + /** MySQL 컨테이너를 Spring Bean으로 등록합니다. @ServiceConnection이 자동으로 DataSource 설정을 처리합니다. */ @Bean @ServiceConnection MySQLContainer mysqlContainer() { + String dbName = System.getProperty("testcontainers.db.name", DEFAULT_DB_NAME); return new MySQLContainer<>(DockerImageName.parse("mysql:8.0.32")) .withReuse(true) - .withDatabaseName("bottlenote") + .withDatabaseName(dbName) .withUsername("root") .withPassword("root"); } diff --git a/build.gradle b/build.gradle index cf527a04a..f884faabb 100644 --- a/build.gradle +++ b/build.gradle @@ -127,6 +127,7 @@ subprojects { useJUnitPlatform { includeTags 'integration' } + systemProperty 'testcontainers.db.name', 'bottlenote_product' } tasks.register('unit_test', Test) { @@ -145,6 +146,7 @@ subprojects { useJUnitPlatform { includeTags 'admin_integration' } + systemProperty 'testcontainers.db.name', 'bottlenote_admin' } // REST Docs 테스트 (app.docs 패키지) diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md index f6147b6f2..dfed0e37f 100644 --- a/plan/claude-ai-harness-improvement.md +++ b/plan/claude-ai-harness-improvement.md @@ -217,40 +217,46 @@ allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent ## 5. 훅 설계 -### 5.1 PostToolUse: 자동 포매팅 +### 5.1 PostToolUse: 자동 포매팅 [구현 완료] -**목적**: Claude가 Java 파일을 수정하면 자동으로 spotless 적용 +**목적**: Claude가 Java/Kotlin 파일을 수정하면 자동으로 spotless 적용 + +**구현 방식**: 별도 스크립트 없이 settings.json 인라인 명령어로 구현 ```json { - "hooks": { - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-format.sh", - "timeout": 30, - "statusMessage": "spotless 포매팅 적용 중..." - } - ] - } - ] - } + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "FP=$(cat | jq -r '.tool_input.file_path // empty'); [[ \"$FP\" == *.java || \"$FP\" == *.kt ]] && cd $CLAUDE_PROJECT_DIR && ./gradlew spotlessApply -q 2>/dev/null || true", + "timeout": 30 + } + ] + } + ] } ``` -**post-edit-format.sh 동작**: -1. stdin으로 전달된 JSON에서 `tool_input.file_path` 추출 -2. `.java` 파일인 경우에만 spotless 실행 -3. mono 또는 product-api 모듈의 파일인지 판별 후 해당 모듈에 spotlessApply -4. `.kt` 파일은 별도 포매터 적용 (ktlint 등, 추후 검토) +**동작**: +1. stdin JSON에서 `tool_input.file_path` 추출 +2. `.java` 또는 `.kt` 파일인 경우에만 spotless 실행 +3. 그 외 파일(.md, .gradle, .xml 등)은 스킵 + +**빌드 설정 (build.gradle)**: +- `bottlenote-mono`, `bottlenote-product-api`: google-java-format (기존) +- `bottlenote-admin-api`: ktlint 추가 (`no-wildcard-imports` 규칙 비활성화) + +**검증 결과**: +- .java 파일: 인덴트/공백 위반 자동 복원 확인 (MD5 해시 일치) +- .kt 파일: 인덴트/공백 위반 자동 복원 확인 (MD5 해시 일치) +- .gradle 파일: Hook 미실행 확인 (의도대로 동작) -**고려사항**: -- spotlessApply는 프로젝트 전체를 포매팅하므로 성능 이슈 가능 -- 대안: google-java-format CLI를 직접 호출하여 단일 파일만 포매팅 -- 타임아웃 30초 설정 (빌드 캐시가 있으면 빠름) +**참고**: +- 초기에 별도 스크립트(post-edit-format.sh)로 모듈별 분기 로직을 구현했으나, 인라인 명령어가 더 간결하여 채택 +- ktlint의 `no-wildcard-imports` 규칙은 auto-fix 불가 (클래스패스 분석 필요) → 비활성화 처리 ### 5.2 PreToolUse: 커밋 메시지 검증 @@ -343,10 +349,11 @@ allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent ### Phase 3: 훅 구현 -- [ ] `post-edit-format.sh` 작성 및 테스트 +- [x] PostToolUse 자동 포매팅 훅 (인라인 명령어 방식, .java + .kt 대응) +- [x] build.gradle에 admin-api Kotlin spotless 설정 추가 (ktlint) +- [x] 훅 동작 검증 (.java, .kt, .gradle 3종 케이스 MD5 해시 비교) - [ ] `pre-commit-validate.sh` 작성 및 테스트 -- [ ] settings.json 업데이트 -- [ ] 훅 성능 측정 (spotless 소요 시간) +- [ ] settings.json에 PreToolUse 커밋 메시지 검증 훅 추가 ### Phase 4: 검증 및 조정 @@ -361,10 +368,10 @@ allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent | # | 질문 | 선택지 | 메모 | |---|------|--------|------| -| 1 | spotless를 PostToolUse에서 실행할지, 커밋 전에만 실행할지 | A: 매 편집마다 / B: 커밋 전만 | A는 정확하지만 느림, B는 빠르지만 중간 상태가 어색 | +| 1 | spotless를 PostToolUse에서 실행할지, 커밋 전에만 실행할지 | A: 매 편집마다 / B: 커밋 전만 | **결정: A** - 매 편집마다 실행, .java/.kt 파일만 필터링하여 불필요한 실행 방지 | | 2 | 스킬 간 공통 규칙(네이밍, 예외 처리)을 어디에 둘지 | A: CLAUDE.md / B: 별도 공통 스킬 / C: 각 스킬에 중복 | A가 가장 단순 | | 3 | admin-api 스킬에 context: fork를 적용할지 | A: fork (독립 컨텍스트) / B: 기본 (대화 공유) | fork면 대화 이력 참조 불가 | -| 4 | Kotlin 파일 포매팅은 어떻게 할지 | A: ktlint / B: ktfmt / C: 당분간 제외 | admin-api는 Kotlin | +| 4 | Kotlin 파일 포매팅은 어떻게 할지 | A: ktlint / B: ktfmt / C: 당분간 제외 | **���정: A** - ktlint 채택, `no-wildcard-imports` 규칙만 비활성화 (auto-fix 불가) | --- From 673acbffb25a2c29cf92e254511156516686facc Mon Sep 17 00:00:00 2001 From: hgkim Date: Mon, 6 Apr 2026 16:01:44 +0900 Subject: [PATCH 21/31] =?UTF-8?q?feat:=20CI=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EA=B4=80=EB=A0=A8=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(SKILL.md)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/verify/SKILL.md | 123 +++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .claude/skills/verify/SKILL.md diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 000000000..11841ffed --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,123 @@ +--- +name: verify +description: | + Local CI verification skill with 3 levels based on implementation stage. + Trigger: "/verify", "/verify quick", "/verify l1", "/verify standard", "/verify l2", "/verify full", "/verify l3", + or when the user says "검증해줘", "빌드 확인", "테스트 돌려줘", "CI 돌려봐". + Always use this skill when the user wants to check if their code compiles, passes tests, or is ready for PR. + This runs checks against the ENTIRE project because all modules share the mono module. +argument-hint: "[quick|standard|full] or [l1|l2|l3]" +--- + +# Verify - Local CI Pipeline + +Run local CI checks matching the project's GitHub Actions pipeline (`.github/workflows/ci_pipeline.yml`). +All checks run against the **entire project** because product-api, admin-api, and batch all depend on mono — changes in one module can break others. + +## Level Selection + +Parse `$ARGUMENTS` to determine the verification level: + +| Argument | Level | When to use | +|----------|-------|-------------| +| `quick`, `l1`, or empty with scaffolding context | L1 | Mockup, scaffolding, initial structure | +| `standard`, `l2`, empty (default) | L2 | Feature implementation complete | +| `full`, `l3` | L3 | Push/PR 직전 최종 검증 | + +If no argument is given, default to **L2 (Standard)**. + +L3 is the final gate before `git push` or PR creation. Integration tests take several minutes and use TestContainers (Docker required), so run L3 only when the feature is complete and you're ready to share the code. During active development, L1/L2 is sufficient. + +## Execution Steps + +Run each step sequentially. **Stop immediately on first failure** — report the error and skip remaining steps. + +### L1 - Quick + +| Step | Command | What it checks | +|------|---------|----------------| +| 1 | `./gradlew compileJava compileTestJava` | Java compilation (all modules) | +| 2 | `./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin` | Kotlin compilation (admin-api) | +| 3 | `./gradlew check_rule_test` | Architecture rules (ArchUnit) | + +### L2 - Standard (includes L1) + +| Step | Command | What it checks | +|------|---------|----------------| +| 4 | `./gradlew unit_test` | Unit tests across all modules | +| 5 | `./gradlew build -x test -x asciidoctor --build-cache --parallel` | Full build (JAR packaging) | + +### L3 - Full (includes L2) + +| Step | Command | What it checks | +|------|---------|----------------| +| 6 | `./gradlew integration_test` | Product integration tests (TestContainers) | +| 7 | `./gradlew admin_integration_test` | Admin integration tests (TestContainers) | + +## How to Run Each Step + +For each step, use the Bash tool: + +1. Record the start time +2. Run the Gradle command +3. Calculate elapsed time +4. Report the result immediately: + +``` +[L1 1/3] Compiling Java... (12s) OK +``` + +Or on failure: +``` +[L1 2/3] Compiling Kotlin... (8s) FAIL +``` + +When a step fails: +- Show the **last 30 lines** of output to help diagnose the error +- Mark all remaining steps as SKIPPED +- Proceed to the summary + +## Output Format + +### Progress (print after each step) + +``` +[L{level} {current}/{total}] {step name}... ({time}) {OK|FAIL} +``` + +### Final Summary (always print) + +``` +Verification Summary (L2 - Standard) +===================================== +Step Status Time +Compile Java OK 12s +Compile Kotlin OK 8s +Rule tests OK 15s +Unit tests OK 45s +Build OK 30s +------------------------------------- +Total PASS 1m50s +``` + +Use `PASS` if all steps succeeded, `FAIL` if any step failed. + +## Timeouts + +| Level | Steps | Expected max time | +|-------|-------|-------------------| +| L1 | 3 steps | ~2 min | +| L2 | 5 steps | ~5 min | +| L3 | 7 steps | ~15 min | + +Set Bash timeout accordingly: +- Compile steps: 120000ms +- Unit/rule tests: 300000ms +- Integration tests: 600000ms +- Build: 300000ms + +## Important + +- Never run module-specific checks — always full project scope +- L3 integration tests require Docker (TestContainers) +- If Docker is not available for L3, report it clearly and suggest falling back to L2 From cb6973def07b380c1757109741063d39e7dd4ecb Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 8 Apr 2026 16:18:22 +0900 Subject: [PATCH 22/31] =?UTF-8?q?docs:=20Mono=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/product-api/SKILL.md | 146 ++++++ .../product-api/references/mono-patterns.md | 246 ++++++++++ .../product-api/references/test-patterns.md | 441 ++++++++++++++++++ 3 files changed, 833 insertions(+) create mode 100644 .claude/skills/product-api/SKILL.md create mode 100644 .claude/skills/product-api/references/mono-patterns.md create mode 100644 .claude/skills/product-api/references/test-patterns.md diff --git a/.claude/skills/product-api/SKILL.md b/.claude/skills/product-api/SKILL.md new file mode 100644 index 000000000..ded1b04e3 --- /dev/null +++ b/.claude/skills/product-api/SKILL.md @@ -0,0 +1,146 @@ +--- +name: product-api +description: | + Product API feature implementation guide for the bottle-note-api-server project. + Trigger: "/product-api", or when the user says "API 추가", "엔드포인트 구현", "product api", "클라이언트 API". + Guides through the full implementation flow: mono module (domain/service) -> product-api (controller) -> tests. + Always use this skill when implementing new features or endpoints in the product-api module. +argument-hint: "[domain] [crud|search|action]" +--- + +# Product API Implementation Guide + +This skill guides you through implementing new features in the product-api module. The project follows a DDD-based multi-module architecture where business logic lives in `bottlenote-mono` and controllers live in `bottlenote-product-api`. + +## Workflow + +Parse `$ARGUMENTS` to identify the target domain and work type, then follow these phases: + +### Phase 0: Explore + +Before writing any code, understand what already exists. + +1. Check if the domain exists in mono: `bottlenote-mono/src/main/java/app/bottlenote/{domain}/` +2. Check existing services, facades, and repositories +3. Check if product-api already has controllers for this domain +4. Identify reusable code vs. what needs to be created + +Report your findings to the user before proceeding. + +### Phase 1: Mono Module (Domain & Business Logic) + +All business logic belongs in `bottlenote-mono`. Read `references/mono-patterns.md` for detailed patterns. + +**Order of implementation:** +1. **Entity/Domain** (if new domain) - `{domain}/domain/` +2. **Repository** (3-tier) - Domain repo -> JPA repo -> QueryDSL (if needed) +3. **DTO** - Request/Response records in `{domain}/dto/request/`, `{domain}/dto/response/` +4. **Exception** - `{domain}/exception/{Domain}Exception.java` + `{Domain}ExceptionCode.java` +5. **Service** - `{Domain}Service` (Command/Query 분리는 필수가 아님, 아래 참고) +6. **Facade** (타 도메인 접근이 필요할 때) - `{Domain}Facade` interface + `Default{Domain}Facade` + +**Service 구조에 대해:** +- Command/Query 분리(`CommandService`/`QueryService`)는 기존 코드에 존재하지만 필수 패턴이 아님 +- 새로운 서비스는 `{Domain}Service` 하나로 작성해도 됨 +- 기존 분리된 서비스를 합칠 필요는 없음, 신규 구현 시 판단 + +**Facade의 역할 - 도메인 간 경계를 보호:** +- Facade는 다른 도메인의 서비스를 직접 호출하지 않고, 해당 도메인의 Facade를 통해 요청하는 패턴 +- 예: UserService가 랭킹 데이터를 직접 조회/수정하면 안 됨 → `RankingFacade`를 통해 요청 +- 이렇게 하면 각 도메인이 자기 내부 구현을 자유롭게 변경할 수 있음 +- Facade 인터페이스는 도메인이 외부에 노출하는 계약(contract)임 + +### Phase 2: Product API (Controller) + +Controllers in product-api are thin - they delegate to mono services/facades. + +**Controller structure:** +```java +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/{domain}") +public class {Domain}Controller { + + private final {Domain}CommandService commandService; + private final {Domain}QueryService queryService; + + @GetMapping + public ResponseEntity list(@ModelAttribute PageableRequest request) { + Long userId = SecurityContextUtil.getUserIdByContext().orElse(-1L); + // ... + return GlobalResponse.ok(response, metaInfos); + } + + @PostMapping + public ResponseEntity create( + @RequestBody @Valid CreateRequest request + ) { + Long userId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new UserException(UserExceptionCode.REQUIRED_USER_ID)); + return GlobalResponse.ok(commandService.create(request, userId)); + } +} +``` + +**Key rules:** +- Path: `@RequestMapping("/api/v1/{plural-resource}")` +- Auth required: `SecurityContextUtil.getUserIdByContext().orElseThrow(...)` +- Auth optional (read): `.orElse(-1L)` +- Response: Always wrap with `GlobalResponse.ok()` +- Pagination: Use `CursorPageable` + `PageResponse` + `MetaInfos` + +### Phase 3: Tests + +Read `references/test-patterns.md` for detailed patterns and test infrastructure. + +**Required (always implement):** +1. **Unit test** (`@Tag("unit")`) - Service logic with Fake/Stub pattern +2. **Integration test** (`@Tag("integration")`) - Full API flow with TestContainers + +**Optional (user request only):** +3. **RestDocs test** (`@Tag("restdocs")`) - API documentation + +Run `/verify quick` after each phase to catch compilation errors early. + +### Phase 4: Verify + +Use the `/verify` skill to validate at each stage: + +| Timing | Command | What it checks | +|--------|---------|----------------| +| After Phase 1 (Mono) | `/verify quick` | Compile + architecture rules | +| After Phase 2 (Controller) | `/verify quick` | Compile + architecture rules | +| After Phase 3 (Tests) | `/verify standard` | Compile + unit tests + build | +| Before push/PR | `/verify full` | Full CI including integration tests | + +## Endpoint Design + +| HTTP Method | Purpose | URL Pattern | Example | +|-------------|---------|-------------|---------| +| GET | List | `/api/v1/{resources}` | `GET /api/v1/reviews` | +| GET | Detail | `/api/v1/{resources}/{id}` | `GET /api/v1/reviews/1` | +| POST | Create | `/api/v1/{resources}` | `POST /api/v1/reviews` | +| PUT | Full update | `/api/v1/{resources}/{id}` | `PUT /api/v1/reviews/1` | +| PATCH | Partial update | `/api/v1/{resources}/{id}` | `PATCH /api/v1/reviews/1` | +| DELETE | Delete | `/api/v1/{resources}/{id}` | `DELETE /api/v1/reviews/1` | + +## Package Structure Reference + +``` +bottlenote-mono/src/main/java/app/bottlenote/{domain}/ +├── constant/ # Enums, constants +├── domain/ # Entities, DomainRepository interface +├── dto/ +│ ├── request/ # Request records (@Valid) +│ ├── response/ # Response records +│ └── dsl/ # QueryDSL criteria +├── event/ # Domain events +├── exception/ # {Domain}Exception + {Domain}ExceptionCode +├── facade/ # {Domain}Facade interface +├── repository/ # Jpa{Domain}Repository, Custom{Domain}Repository +└── service/ # {Domain}CommandService, {Domain}QueryService + +bottlenote-product-api/src/main/java/app/bottlenote/{domain}/ +└── controller/ # {Domain}Controller (thin, delegates to mono) +``` diff --git a/.claude/skills/product-api/references/mono-patterns.md b/.claude/skills/product-api/references/mono-patterns.md new file mode 100644 index 000000000..d6cfbd9d6 --- /dev/null +++ b/.claude/skills/product-api/references/mono-patterns.md @@ -0,0 +1,246 @@ +# Mono Module Patterns + +## Repository 3-Tier Pattern + +### 1. Domain Repository (Required) +Pure business interface - no Spring/JPA dependency. + +```java +// Location: {domain}/domain/{Domain}Repository.java +@DomainRepository +public interface RatingRepository { + Rating save(Rating rating); + Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId); +} +``` + +### 2. JPA Repository (Required) +Implements domain repo + extends JpaRepository. + +```java +// Location: {domain}/repository/Jpa{Domain}Repository.java +@JpaRepositoryImpl +public interface JpaRatingRepository + extends JpaRepository, RatingRepository, CustomRatingRepository { +} +``` + +### 3. QueryDSL Custom Repository (Optional - complex queries only) + +```java +// Interface: {domain}/repository/Custom{Domain}Repository.java +public interface CustomRatingRepository { + PageResponse fetchRatingList(RatingListFetchCriteria criteria); +} + +// Implementation: {domain}/repository/Custom{Domain}RepositoryImpl.java +public class CustomRatingRepositoryImpl implements CustomRatingRepository { + // JPAQueryFactory injection, BooleanBuilder for dynamic conditions +} + +// Query supporter: {domain}/repository/{Domain}QuerySupporter.java +@Component +public class RatingQuerySupporter { + // Reusable query fragments +} +``` + +**Use QueryDSL only for:** dynamic multi-condition filters, multi-table joins, complex projections. +**Do NOT use for:** simple CRUD, single-condition lookups (use method query or @Query JPQL). + +## Service Pattern + +서비스는 `{Domain}Service` 하나로 작성하는 것이 기본이다. +기존 코드에 Command/Query 분리(`CommandService`/`QueryService`)가 있지만 필수 패턴이 아니며, 신규 구현 시 하나로 작성해도 됨. 기존 분리된 서비스를 굳이 합칠 필요는 없음. + +```java +@Service +@RequiredArgsConstructor +public class RatingService { + private final RatingRepository ratingRepository; + private final AlcoholFacade alcoholFacade; // 타 도메인 접근은 반드시 Facade를 통해 + + @Transactional + public RatingRegisterResponse register(Long alcoholId, Long userId, RatingPoint ratingPoint) { + Objects.requireNonNull(alcoholId, "alcoholId must not be null"); + + // 타 도메인 검증 - AlcoholFacade를 통해 요청 + if (FALSE.equals(alcoholFacade.existsByAlcoholId(alcoholId))) { + throw new RatingException(RatingExceptionCode.ALCOHOL_NOT_FOUND); + } + + // 자기 도메인 로직 + Rating rating = ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId) + .orElse(Rating.builder().alcoholId(alcoholId).userId(userId).build()); + rating.registerRatingPoint(ratingPoint); + ratingRepository.save(rating); + + // 이벤트 발행 (부수 효과) + eventPublisher.publishEvent(new RatingRegistryEvent(alcoholId, userId)); + + return new RatingRegisterResponse(rating.getId()); + } + + @Transactional(readOnly = true) + public PageResponse fetchList(RatingListFetchCriteria criteria) { + // 읽기 전용 메서드는 readOnly = true + } +} +``` + +## Aggregate Root & Facade Pattern + +### Aggregate 개념 + +도메인은 Aggregate 단위로 묶인다. 절대적인 규칙은 아니지만 개념적 경계로 활용한다. + +``` +ranking (Aggregate Root) +├── RankingService ← 외부에서 접근 가능 (Facade를 통해) +├── RankingPointService ← 내부 구현, 외부 접근 불가 +├── RankingHistoryService ← 내부 구현, 외부 접근 불가 +└── RankingFacade ← 외부에 노출하는 유일한 창구 +``` + +외부 도메인은 Aggregate Root(= Facade)를 통해서만 접근한다. Aggregate 내부의 하위 서비스에 직접 접근하면 안 된다. + +``` +[OK] UserService → RankingFacade (Aggregate Root 접근) +[OK] UserProfileService → RankingFacade (Aggregate Root 접근) +[NO] UserService → RankingPointService (하위 도메인 직접 접근) +[NO] UserProfileService → RankingHistoryService (하위 도메인 직접 접근) +``` + +### Facade의 역할 + +Facade는 Aggregate Root로서 도메인 간 경계를 보호한다. + +**왜 필요한가:** +- UserService가 RankingPointService를 직접 호출하면, Ranking 내부 구조 변경 시 UserService도 깨짐 +- RankingFacade를 통해 요청하면, Ranking이 내부 구현(서비스 분리, 테이블 구조, 캐시 전략)을 자유롭게 변경 가능 +- Facade 인터페이스는 해당 도메인이 외부에 노출하는 계약(contract) + +**원칙:** +- 같은 Aggregate 내에서는 Repository/Service를 직접 사용 +- 다른 Aggregate의 데이터가 필요하면 반드시 해당 Aggregate의 Facade를 통해 접근 +- Facade는 외부에 필요한 최소한의 인터페이스만 노출 + +``` +[OK] UserService → UserRepository (같은 Aggregate) +[OK] UserService → AlcoholFacade (타 Aggregate의 Facade) +[NO] UserService → AlcoholRepository (타 Aggregate 직접 접근) +[NO] UserService → RankingPointService (타 Aggregate 하위 서비스 직접 호출) +``` + +```java +// Interface: {domain}/facade/{Domain}Facade.java +public interface AlcoholFacade { + Boolean existsByAlcoholId(Long alcoholId); + AlcoholInfo getAlcoholInfo(Long alcoholId); +} + +// Implementation: {domain}/service/Default{Domain}Facade.java +@FacadeService +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DefaultAlcoholFacade implements AlcoholFacade { + private final AlcoholRepository alcoholRepository; + // 자기 도메인의 Repository만 사용 +} +``` + +## DTO Patterns + +```java +// Request with validation +public record RatingRegisterRequest( + @NotNull Long alcoholId, + @NotNull Double rating +) {} + +// Pageable request with defaults +public record ReviewPageableRequest( + ReviewSortType sortType, SortOrder sortOrder, Long cursor, Long pageSize +) { + @Builder + public ReviewPageableRequest { + sortType = sortType != null ? sortType : ReviewSortType.POPULAR; + cursor = cursor != null ? cursor : 0L; + pageSize = pageSize != null ? pageSize : 10L; + } +} + +// Response with factory method +public record RatingListFetchResponse(Long totalCount, List ratings) { + public record Info(Long ratingId, Long alcoholId, Double rating) {} + public static RatingListFetchResponse create(Long total, List infos) { + return new RatingListFetchResponse(total, infos); + } +} +``` + +## Exception Pattern + +```java +// {domain}/exception/{Domain}Exception.java +public class RatingException extends AbstractCustomException { + public RatingException(RatingExceptionCode code) { + super(code); + } +} + +// {domain}/exception/{Domain}ExceptionCode.java +@Getter +public enum RatingExceptionCode implements ExceptionCode { + INVALID_RATING_POINT(HttpStatus.BAD_REQUEST, "invalid rating point"), + ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "alcohol not found"); + + private final HttpStatus httpStatus; + private final String message; + + RatingExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} +``` + +## Event Pattern + +```java +// Event record +public record RatingRegistryEvent(Long alcoholId, Long userId) {} + +// Listener +@DomainEventListener +@RequiredArgsConstructor +public class RatingEventListener { + @TransactionalEventListener + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleRatingRegistry(RatingRegistryEvent event) { + // Side effects in separate transaction + } +} +``` + +## Cursor Pagination + +```java +// In service/repository +public PageResponse fetchList(Criteria criteria) { + List items = queryFactory.selectFrom(...) + .where(cursorCondition(criteria.cursor())) + .limit(criteria.pageSize() + 1) // fetch one extra to detect hasNext + .fetch(); + + CursorPageable pageable = CursorPageable.of(items, criteria.cursor(), criteria.pageSize()); + return PageResponse.of(items.subList(0, Math.min(items.size(), criteria.pageSize())), pageable); +} + +// In controller +MetaInfos metaInfos = MetaService.createMetaInfo(); +metaInfos.add("pageable", response.cursorPageable()); +metaInfos.add("searchParameters", request); +return GlobalResponse.ok(response, metaInfos); +``` diff --git a/.claude/skills/product-api/references/test-patterns.md b/.claude/skills/product-api/references/test-patterns.md new file mode 100644 index 000000000..f07ac271f --- /dev/null +++ b/.claude/skills/product-api/references/test-patterns.md @@ -0,0 +1,441 @@ +# Test Patterns for Product API + +## Test Classification + +| Tag | Type | Base Class | Location | +|-----|------|------------|----------| +| `@Tag("unit")` | Unit test | None (plain JUnit) | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/service/` | +| `@Tag("integration")` | Integration test | `IntegrationTestSupport` | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/integration/` | +| (none) | RestDocs test | `AbstractRestDocs` | `bottlenote-product-api/src/test/java/app/docs/{domain}/` | + +--- + +## Test Infrastructure + +Read this section first — these are the shared utilities available across all test types. + +### IntegrationTestSupport + +Base class for all integration tests. Location: `bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java` + +**Provided fields:** +- `ObjectMapper mapper` - JSON serialization +- `MockMvc mockMvc` - legacy Spring MVC test API +- `MockMvcTester mockMvcTester` - modern Spring 6+ fluent API (prefer this) +- `TestAuthenticationSupport authSupport` - token generation +- `DataInitializer dataInitializer` - DB cleanup + +**Provided helper methods:** +- `getToken()` - default user token (3 overloads: no-arg, User, userId) +- `getTokenUserId()` - get userId from default token +- `extractData(MvcTestResult, Class)` - parse GlobalResponse.data into target type +- `extractData(MvcResult, Class)` - legacy MockMvc version +- `parseResponse(MvcTestResult)` - parse raw response to GlobalResponse +- `parseResponse(MvcResult)` - legacy version + +**Auto cleanup:** `@AfterEach` calls `dataInitializer.deleteAll()` which TRUNCATEs all tables except system tables (databasechangelog, flyway, schema_version). + +### TestContainersConfig + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` + +Containers (all with `@ServiceConnection` for auto-wiring): +- **MySQL 8.0.32** - `withReuse(true)`, DB name from `System.getProperty("testcontainers.db.name", "bottlenote")` +- **Redis 7.0.12** - `withReuse(true)` +- **MinIO** - S3-compatible storage with `AmazonS3` client bean + +Fake beans (`@Primary`, replaces real implementations in test context): +- `FakeWebhookRestTemplate` - captures HTTP calls instead of sending +- `FakeProfanityClient` - returns clean text without calling external API + +### TestAuthenticationSupport + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java` + +- `getFirstUser()` - first user from DB +- `getAccessToken()` - default access token +- `getRandomAccessToken()` - random user token +- `createToken(OauthRequest)` / `createToken(User)` - custom token generation +- `getDefaultUserId()` / `getUserId(String email)` - user ID lookup + +### DataInitializer + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java` + +- `deleteAll()` - TRUNCATE all user tables (dynamic table discovery via `SHOW TABLES`) +- `refreshCache()` - refresh table list for dynamically created tables +- `@Transactional(REQUIRES_NEW)` for isolation +- Filters: `databasechangelog*`, `flyway_*`, `schema_version`, `BATCH_*`, `QRTZ_*` + +--- + +## Test Data Patterns + +### TestFactory (Integration tests) + +Location: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/{Domain}TestFactory.java` + +TestFactory uses `EntityManager` + `@Transactional` (not JPA repositories). Method naming: `persist{Entity}()`. + +```java +@Component +public class RatingTestFactory { + @PersistenceContext + private EntityManager em; + + @Autowired + private AlcoholTestFactory alcoholTestFactory; + + private final AtomicInteger counter = new AtomicInteger(0); + + @Transactional + public Rating persistRating(Long alcoholId, Long userId) { + Rating rating = Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + em.persist(rating); + em.flush(); + return rating; + } +} +``` + +Key patterns: +- `AtomicInteger counter` for unique suffixes (names, emails) +- `persistAndFlush()` variants for immediate ID access +- Compose other factories: `AlcoholTestFactory` uses `RegionTestFactory`, `DistilleryTestFactory` + +### ObjectFixture (Unit tests) + +Location: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/{Domain}ObjectFixture.java` + +Static factory methods returning pre-configured domain objects. No DB, no Spring. + +```java +public class RatingObjectFixture { + public static Rating createRating(Long alcoholId, Long userId) { + return Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + } + + public static RatingRegisterRequest createRegisterRequest() { + return new RatingRegisterRequest(1L, 4.5); + } +} +``` + +Use ObjectFixture for unit tests, TestFactory for integration tests. + +--- + +## Existing Fake/InMemory Implementations + +Before creating a new Fake, check if one already exists. + +### InMemory Repositories +- `InMemoryRatingRepository`, `InMemoryReviewRepository`, `InMemoryLikesRepository` +- `InMemoryUserRepository`, `InMemoryUserQueryRepository` +- `InMemoryAlcoholQueryRepository`, `InMemoryFollowRepository` +- `InMemoryPicksRepository` (also `FakePicksRepository` in `picks/fake/`) +- Plus others for block, support, banner, etc. + +### Fake Services/Facades +- `FakeAlcoholFacade` - in-memory with `add()`, `remove()`, `clear()` +- `FakeUserFacade` - similar pattern +- `FakeHistoryEventPublisher` - captures published history events +- `FakeApplicationEventPublisher` - captures all Spring events (`getPublishedEvents()`, `getPublishedEventsOfType()`, `hasPublishedEventOfType()`, `clear()`) + +### Fake External Services +- `FakeWebhookRestTemplate` - captures HTTP calls (`getCallCount()`, `getLastRequestBody()`) +- `FakeProfanityClient` - returns input text as-is +- Fake JWT/BCrypt implementations for security testing + +--- + +## 1. Unit Test - Fake/Stub Pattern (Preferred) + +Mock 대신 InMemory 구현체를 사용하는 패턴. 실제 동작에 가깝고 리팩토링에 강하다. + +### Fake Repository + +```java +// Location: {domain}/fixture/InMemory{Domain}Repository.java +public class InMemoryRatingRepository implements RatingRepository { + private final Map database = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Rating save(Rating rating) { + if (rating.getId() == null) { + ReflectionTestUtils.setField(rating, "id", idGenerator.getAndIncrement()); + } + database.put(rating.getId(), rating); + return rating; + } + + @Override + public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { + return database.values().stream() + .filter(r -> r.getAlcoholId().equals(alcoholId) && r.getUserId().equals(userId)) + .findFirst(); + } +} +``` + +### Fake Test + +```java +// Location: {domain}/service/Fake{Domain}ServiceTest.java +@Tag("unit") +@DisplayName("{Domain} 서비스 단위 테스트") +class FakeRatingCommandServiceTest { + + private RatingCommandService sut; // system under test + private InMemoryRatingRepository ratingRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + ratingRepository = new InMemoryRatingRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + sut = new RatingCommandService(ratingRepository, eventPublisher, /* other fakes */); + } + + @Nested + @DisplayName("평점 등록 시") + class RegisterRating { + + @Test + @DisplayName("유효한 요청이면 평점을 등록할 수 있다") + void register_whenValidRequest_savesRating() { + // given + Long alcoholId = 1L; + Long userId = 1L; + RatingPoint point = RatingPoint.FIVE; + + // when + RatingRegisterResponse response = sut.register(alcoholId, userId, point); + + // then + assertThat(response).isNotNull(); + assertThat(ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId)).isPresent(); + } + + @Test + @DisplayName("이벤트가 발행된다") + void register_publishesEvent() { + sut.register(1L, 1L, RatingPoint.FIVE); + + assertThat(eventPublisher.getPublishedEvents()) + .hasSize(1) + .first() + .isInstanceOf(RatingRegistryEvent.class); + } + } +} +``` + +## 2. Unit Test - Mockito (Last Resort Only) + +Mockito는 최후의 수단이다. Fake/Stub으로 해결할 수 없는 경우에만 사용한다. +Mock은 구현 세부사항에 결합되어 리팩토링 시 깨지기 쉽고, 실제 동작을 검증하지 못한다. + +**Mockito 사용 전 반드시 사용자에게 먼저 확인:** +- Fake 구현의 공수가 과도하게 큰 경우 (외부 시스템 연동, 복잡한 인프라 의존 등) +- 사용자에게 "Fake 구현 공수가 크니 Mock으로 타협할까요?" 라고 대화를 먼저 시작할 것 +- 사용자 동의 없이 Mockito를 선택하지 말 것 + +```java +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RatingCommandServiceTest { + + @InjectMocks + private RatingCommandService sut; + + @Mock + private RatingRepository ratingRepository; + + @Mock + private AlcoholFacade alcoholFacade; + + @Test + @DisplayName("존재하지 않는 주류에 평점을 등록하면 예외가 발생한다") + void register_whenAlcoholNotFound_throwsException() { + // given + given(alcoholFacade.existsByAlcoholId(anyLong())).willReturn(false); + + // when & then + assertThatThrownBy(() -> sut.register(1L, 1L, RatingPoint.FIVE)) + .isInstanceOf(RatingException.class); + } +} +``` + +## 3. Integration Test + +Full Spring context with TestContainers (real DB). + +```java +// Location: {domain}/integration/{Domain}IntegrationTest.java +@Tag("integration") +@DisplayName("{Domain} 통합 테스트") +class RatingIntegrationTest extends IntegrationTestSupport { + + @Autowired + private RatingTestFactory ratingTestFactory; + + @Test + @DisplayName("평점을 등록할 수 있다") + void registerRating() { + // given - TestFactory로 데이터 생성 + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + String token = getToken(user); + + RatingRegisterRequest request = new RatingRegisterRequest(alcohol.getId(), 4.5); + + // when - MockMvcTester (modern API) + MvcTestResult result = mockMvcTester.post() + .uri("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .content(mapper.writeValueAsString(request)) + .exchange(); + + // then - helper methods + assertThat(result).hasStatusOk(); + GlobalResponse response = parseResponse(result); + assertThat(response.getSuccess()).isTrue(); + } +} +``` + +### Async Event Wait (Awaitility) + +```java +@Test +void register_triggersHistoryEvent() { + // ... perform action ... + + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List histories = historyRepository.findByUserId(userId); + assertThat(histories).hasSize(1); + }); +} +``` + +## 4. RestDocs Test + +API documentation with Spring REST Docs. + +```java +// Location: app/docs/{domain}/Rest{Domain}ControllerDocsTest.java +class RestRatingControllerDocsTest extends AbstractRestDocs { + + @MockBean + private RatingCommandService commandService; + + @MockBean + private RatingQueryService queryService; + + @Override + protected Object initController() { + return new RatingController(commandService, queryService); + } + + @Test + @DisplayName("평점 등록 API 문서화") + void registerRating() throws Exception { + // given + given(commandService.register(anyLong(), anyLong(), any())) + .willReturn(new RatingRegisterResponse(1L)); + + mockSecurityContext(1L); // static mock for SecurityContextUtil + + // when & then + mockMvc.perform(post("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andDo(document("rating-register", + requestFields( + fieldWithPath("alcoholId").type(NUMBER).description("주류 ID"), + fieldWithPath("rating").type(NUMBER).description("평점") + ), + responseFields( + fieldWithPath("success").type(BOOLEAN).description("성공 여부"), + fieldWithPath("code").type(NUMBER).description("상태 코드"), + fieldWithPath("data").type(OBJECT).description("응답 데이터"), + fieldWithPath("data.ratingId").type(NUMBER).description("평점 ID"), + fieldWithPath("errors").type(ARRAY).description("에러 목록"), + fieldWithPath("meta").type(OBJECT).description("메타 정보"), + fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), + fieldWithPath("meta.serverEncoding").type(STRING).description("인코딩"), + fieldWithPath("meta.serverResponseTime").type(STRING).description("응답 시간"), + fieldWithPath("meta.serverPathVersion").type(STRING).description("API 버전") + ) + )); + } +} +``` + +Note: RestDocs tests use `AbstractRestDocs` which sets up standalone MockMvc with `MockMvcBuilders.standaloneSetup()`, pretty-print configuration, and `GlobalExceptionHandler`. These tests use `@MockBean` for services — this is the one place where mocking is acceptable because we're testing documentation, not business logic. + +--- + +--- + +## What to Implement + +기능 구현 시 어떤 테스트를 만들어야 하는지 기준. + +### Required + +| 구현 대상 | 테스트 타입 | 태그 | 위치 | +|-----------|-----------|------|------| +| Service (비즈니스 로직) | Unit (Fake/Stub) | `@Tag("unit")` | `{domain}/service/Fake{Domain}ServiceTest.java` | +| Controller (API 엔드포인트) | Integration | `@Tag("integration")` | `{domain}/integration/{Domain}IntegrationTest.java` | + +- Unit 테스트는 **반드시** Fake/Stub 패턴으로 작성 +- Integration 테스트는 **반드시** `IntegrationTestSupport` 상속 + +### Optional (사용자 요청 시) + +| 구현 대상 | 테스트 타입 | 태그 | 위치 | +|-----------|-----------|------|------| +| API 문서화 | RestDocs | `@Tag("restdocs")` | `app/docs/{domain}/Rest{Domain}ControllerDocsTest.java` | + +RestDocs는 사용자가 API 문서화를 요청한 경우에만 작성한다. + +### Tag Reference + +새 테스트 작성 시 반드시 아래 태그를 사용: + +```java +@Tag("unit") // 단위 테스트 → ./gradlew unit_test +@Tag("integration") // product 통합 테스트 → ./gradlew integration_test +@Tag("admin_integration") // admin 통합 테스트 → ./gradlew admin_integration_test +@Tag("restdocs") // API 문서화 테스트 → ./gradlew restDocsTest +``` + +### Verify Skill 연계 + +테스트 작성 후 `/verify` 스킬로 검증: +- **구현 중**: `/verify quick` (컴파일 + 아키텍처 규칙 통과 확인) +- **테스트 작성 완료**: `/verify standard` (컴파일 + unit test + build) +- **push 직전**: `/verify full` (전체 CI - integration 포함) + +## Test Naming Convention + +- Class: `Fake{Feature}ServiceTest`, `{Feature}IntegrationTest`, `Rest{Domain}ControllerDocsTest` +- Method: `{action}_{scenario}_{expectedResult}` or Korean `@DisplayName` +- DisplayName format: `~할 때 ~한다`, `~하면 ~할 수 있다` From ed7f04b93c1073038c8fa366d120ab19451d0812 Mon Sep 17 00:00:00 2001 From: hgkim Date: Wed, 8 Apr 2026 17:02:34 +0900 Subject: [PATCH 23/31] =?UTF-8?q?refactor:=20implement-product-api/impleme?= =?UTF-8?q?nt-test=20=EC=8A=A4=ED=82=AC=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product-api -> implement-product-api 스킬 리네임 - Phase 0에 여파 분석 추가 (이벤트, 트랜잭션, 파급 범위, 캐시, 스키마) - 테스트 구현을 implement-test 독립 스킬로 분리 - implement-test에 시나리오 정의 Phase 추가 - test-patterns.md를 test-infra.md + test-patterns.md로 분할 - 한글/영어 혼용 정리 --- .../SKILL.md | 59 ++- .../references/mono-patterns.md | 0 .claude/skills/implement-test/SKILL.md | 151 ++++++ .../implement-test/references/test-infra.md | 149 ++++++ .../references/test-patterns.md | 230 +++++++++ .../product-api/references/test-patterns.md | 441 ------------------ 6 files changed, 559 insertions(+), 471 deletions(-) rename .claude/skills/{product-api => implement-product-api}/SKILL.md (71%) rename .claude/skills/{product-api => implement-product-api}/references/mono-patterns.md (100%) create mode 100644 .claude/skills/implement-test/SKILL.md create mode 100644 .claude/skills/implement-test/references/test-infra.md create mode 100644 .claude/skills/implement-test/references/test-patterns.md delete mode 100644 .claude/skills/product-api/references/test-patterns.md diff --git a/.claude/skills/product-api/SKILL.md b/.claude/skills/implement-product-api/SKILL.md similarity index 71% rename from .claude/skills/product-api/SKILL.md rename to .claude/skills/implement-product-api/SKILL.md index ded1b04e3..80a75df73 100644 --- a/.claude/skills/product-api/SKILL.md +++ b/.claude/skills/implement-product-api/SKILL.md @@ -1,9 +1,10 @@ --- -name: product-api +name: implement-product-api description: | Product API feature implementation guide for the bottle-note-api-server project. - Trigger: "/product-api", or when the user says "API 추가", "엔드포인트 구현", "product api", "클라이언트 API". - Guides through the full implementation flow: mono module (domain/service) -> product-api (controller) -> tests. + Trigger: "/implement-product-api", or when the user says "API 추가", "엔드포인트 구현", "product api", "클라이언트 API". + Guides through the implementation flow: mono module (domain/service) -> product-api (controller). + For test implementation, use /implement-test after this skill completes. Always use this skill when implementing new features or endpoints in the product-api module. argument-hint: "[domain] [crud|search|action]" --- @@ -18,14 +19,22 @@ Parse `$ARGUMENTS` to identify the target domain and work type, then follow thes ### Phase 0: Explore -Before writing any code, understand what already exists. +Before writing any code, understand what already exists and what will be affected. +**Codebase scan:** 1. Check if the domain exists in mono: `bottlenote-mono/src/main/java/app/bottlenote/{domain}/` 2. Check existing services, facades, and repositories 3. Check if product-api already has controllers for this domain 4. Identify reusable code vs. what needs to be created -Report your findings to the user before proceeding. +**Impact analysis:** +5. **Events** - related domain events (publish/subscribe), whether new events are needed +6. **Transactions** - propagation policy when calling across Facades, need for `@Async` separation +7. **Ripple scope** - files affected if Repository/Facade interfaces change (other services, InMemory implementations, tests) +8. **Cache** - whether the target data is cached (`@Cacheable`, Caffeine, Redis), invalidation strategy needed +9. **Schema** - whether Entity changes require a Liquibase migration + +Report both findings and impact to the user before proceeding. ### Phase 1: Mono Module (Domain & Business Logic) @@ -39,16 +48,16 @@ All business logic belongs in `bottlenote-mono`. Read `references/mono-patterns. 5. **Service** - `{Domain}Service` (Command/Query 분리는 필수가 아님, 아래 참고) 6. **Facade** (타 도메인 접근이 필요할 때) - `{Domain}Facade` interface + `Default{Domain}Facade` -**Service 구조에 대해:** -- Command/Query 분리(`CommandService`/`QueryService`)는 기존 코드에 존재하지만 필수 패턴이 아님 -- 새로운 서비스는 `{Domain}Service` 하나로 작성해도 됨 -- 기존 분리된 서비스를 합칠 필요는 없음, 신규 구현 시 판단 +**Service structure:** +- Command/Query split (`CommandService`/`QueryService`) exists in codebase but is not mandatory +- New services can be a single `{Domain}Service` +- No need to merge existing split services; decide per new implementation -**Facade의 역할 - 도메인 간 경계를 보호:** -- Facade는 다른 도메인의 서비스를 직접 호출하지 않고, 해당 도메인의 Facade를 통해 요청하는 패턴 -- 예: UserService가 랭킹 데이터를 직접 조회/수정하면 안 됨 → `RankingFacade`를 통해 요청 -- 이렇게 하면 각 도메인이 자기 내부 구현을 자유롭게 변경할 수 있음 -- Facade 인터페이스는 도메인이 외부에 노출하는 계약(contract)임 +**Facade role - protecting domain boundaries:** +- Facade prevents direct cross-domain service calls; request through the target domain's Facade instead +- Example: UserService must not query ranking data directly; use `RankingFacade` +- This allows each domain to freely change its internal implementation +- Facade interface is the contract a domain exposes to the outside ### Phase 2: Product API (Controller) @@ -90,30 +99,20 @@ public class {Domain}Controller { - Response: Always wrap with `GlobalResponse.ok()` - Pagination: Use `CursorPageable` + `PageResponse` + `MetaInfos` -### Phase 3: Tests - -Read `references/test-patterns.md` for detailed patterns and test infrastructure. - -**Required (always implement):** -1. **Unit test** (`@Tag("unit")`) - Service logic with Fake/Stub pattern -2. **Integration test** (`@Tag("integration")`) - Full API flow with TestContainers - -**Optional (user request only):** -3. **RestDocs test** (`@Tag("restdocs")`) - API documentation +### Phase 3: Verify -Run `/verify quick` after each phase to catch compilation errors early. - -### Phase 4: Verify - -Use the `/verify` skill to validate at each stage: +Use the `/verify` skill to validate: | Timing | Command | What it checks | |--------|---------|----------------| | After Phase 1 (Mono) | `/verify quick` | Compile + architecture rules | | After Phase 2 (Controller) | `/verify quick` | Compile + architecture rules | -| After Phase 3 (Tests) | `/verify standard` | Compile + unit tests + build | | Before push/PR | `/verify full` | Full CI including integration tests | +### Next: Tests + +After implementation is verified, use `/implement-test {domain} product` to create tests. + ## Endpoint Design | HTTP Method | Purpose | URL Pattern | Example | diff --git a/.claude/skills/product-api/references/mono-patterns.md b/.claude/skills/implement-product-api/references/mono-patterns.md similarity index 100% rename from .claude/skills/product-api/references/mono-patterns.md rename to .claude/skills/implement-product-api/references/mono-patterns.md diff --git a/.claude/skills/implement-test/SKILL.md b/.claude/skills/implement-test/SKILL.md new file mode 100644 index 000000000..0c25f0f5f --- /dev/null +++ b/.claude/skills/implement-test/SKILL.md @@ -0,0 +1,151 @@ +--- +name: implement-test +description: | + Test implementation guide for bottle-note-api-server (product-api & admin-api). + Trigger: "/implement-test", or when the user says "테스트 작성", "테스트 구현", "테스트 추가", "write tests", "implement tests". + Guides through unit test (Fake/Stub), integration test, and RestDocs test creation. + Supports both product-api (Java) and admin-api (Kotlin) modules. +argument-hint: "[domain] [product|admin] [unit|integration|restdocs|all]" +--- + +# Test Implementation Guide + +References: +- `references/test-infra.md` - shared test utilities, TestContainers, existing Fake/InMemory list +- `references/test-patterns.md` - unit, integration, RestDocs code patterns + +## Argument Parsing + +Parse `$ARGUMENTS` to determine: +- **domain**: target domain (e.g., `alcohols`, `rating`, `review`) +- **module**: `product` (default) or `admin` +- **scope**: `unit`, `integration`, `restdocs`, or `all` (default: `unit` + `integration`) + +## Phase 0: Explore + +Before writing tests, understand the implementation: + +1. Read the service class to identify testable methods and branches +2. Check existing test infrastructure: + - Fake/InMemory repositories for the domain + - TestFactory for the domain + - ObjectFixture for the domain +3. Report findings: what exists, what needs to be created + +## Phase 1: Scenario Definition + +Define test scenario lists based on service methods and API endpoints. +Write each scenario in `@DisplayName` format (`when ~, should ~`) and get user approval before implementation. + +**Unit test scenarios** (per service method): +- Success: expected behavior with valid input +- Failure: exception conditions (not found, unauthorized, duplicate, etc.) +- Edge cases: null, empty, boundary values + +**Integration test scenarios** (per API endpoint): +- Authenticated request + successful response +- Authentication failure (401) +- Business validation failure (400, 404, 409, etc.) + +Example output (scenarios must be written in Korean for `@DisplayName`): +``` +Unit: RatingService +- 유효한 요청이면 평점을 등록할 수 있다 +- 존재하지 않는 주류에 평점을 등록하면 예외가 발생한다 +- 이미 평점이 있으면 기존 평점을 갱신한다 +- 평점 등록 시 이벤트가 발행된다 + +Integration: POST /api/v1/ratings +- 인증된 사용자가 평점을 등록할 수 있다 +- 인증 없이 요청하면 401을 반환한다 +- 존재하지 않는 주류 ID로 요청하면 404를 반환한다 +``` + +Present the scenario list to the user and proceed to Phase 2 after approval. + +## Phase 2: Test Infrastructure (create if missing) + +### For Unit Tests + +Check and create if needed: +- `InMemory{Domain}Repository` in `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/` +- `{Domain}ObjectFixture` in the same fixture package + +### For Integration Tests + +Check and create if needed: + +**Product module:** +- `{Domain}TestFactory` in `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/` + +**Admin module:** +- `{Domain}Helper` (Kotlin object) in `bottlenote-admin-api/src/test/kotlin/app/helper/{domain}/` + +## Phase 3: Test Implementation + +Read `references/test-patterns.md` for code examples before writing tests. + +### Unit Test (`@Tag("unit")`) + +**Required for:** Service classes with business logic. + +| Item | Product (Java) | Admin | +|------|---------------|-------| +| Location | `product-api/.../app/bottlenote/{domain}/service/` | N/A (business logic is in mono) | +| Pattern | Fake/Stub (Mock is last resort, ask user first) | - | +| Naming | `Fake{Domain}ServiceTest` | - | + +Structure: +- `@BeforeEach`: wire SUT with InMemory repos + Fake facades +- `@Nested` + `@DisplayName`: group by method/scenario +- Given-When-Then in each test + +### Integration Test (`@Tag("integration")` or `@Tag("admin_integration")`) + +**Required for:** All API endpoints. + +| Item | Product (Java) | Admin (Kotlin) | +|------|---------------|----------------| +| Location | `product-api/.../app/bottlenote/{domain}/integration/` | `admin-api/.../app/integration/{domain}/` | +| Base class | `IntegrationTestSupport` | `IntegrationTestSupport` | +| Tag | `@Tag("integration")` | `@Tag("admin_integration")` | +| API client | `mockMvcTester` | `mockMvcTester` | +| Auth | `getToken()` / `getToken(user)` | `getAccessToken(admin)` | +| Data setup | `{Domain}TestFactory` (`@Autowired`) | `{Domain}TestFactory` (`@Autowired`) | + +Key patterns: +- `@Nested` per API endpoint +- Auth success + failure cases for each endpoint +- `extractData(result, ResponseType.class)` for response parsing +- `Awaitility` for async event verification + +### RestDocs Test (optional, user request only) + +| Item | Product (Java) | Admin (Kotlin) | +|------|---------------|----------------| +| Location | `product-api/.../app/docs/{domain}/` | `admin-api/.../app/docs/{domain}/` | +| Base class | `AbstractRestDocs` | `@WebMvcTest(excludeAutoConfiguration = [SecurityAutoConfiguration::class])` | +| Naming | `Rest{Domain}ControllerDocsTest` | `Admin{Domain}ControllerDocsTest` | +| Mocking | `@MockBean` services (acceptable here) | `@MockitoBean` services | + +## Phase 4: Verify + +After test implementation, run verification: + +| Scope | Command | +|-------|---------| +| Unit tests only | `/verify standard` (compile + unit + build) | +| With integration | `/verify full` (includes integration tests) | + +## Test Naming Convention + +- Class: `Fake{Feature}ServiceTest`, `{Feature}IntegrationTest`, `Rest{Domain}ControllerDocsTest` +- Method: `{action}_{scenario}_{expectedResult}` or Korean `@DisplayName` +- DisplayName: always in Korean, format `~할 때 ~한다`, `~하면 ~할 수 있다` + +## Important Rules + +- **Mock is last resort**: always prefer Fake/InMemory. Ask user before using Mockito. +- **RestDocs is optional**: only implement when user explicitly requests. +- **One test, one scenario**: each `@Test` verifies a single behavior. +- **Repository interface changes**: if you added methods to a domain repository, update the corresponding `InMemory{Domain}Repository` too. diff --git a/.claude/skills/implement-test/references/test-infra.md b/.claude/skills/implement-test/references/test-infra.md new file mode 100644 index 000000000..976146506 --- /dev/null +++ b/.claude/skills/implement-test/references/test-infra.md @@ -0,0 +1,149 @@ +# Test Infrastructure + +## Test Classification + +| Tag | Type | Base Class | Location | +|-----|------|------------|----------| +| `@Tag("unit")` | Unit test | None (plain JUnit) | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/service/` | +| `@Tag("integration")` | Integration test | `IntegrationTestSupport` | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/integration/` | +| `@Tag("admin_integration")` | Admin integration | `IntegrationTestSupport` | `bottlenote-admin-api/src/test/kotlin/app/integration/{domain}/` | +| (none) | RestDocs test | `AbstractRestDocs` | `bottlenote-product-api/src/test/java/app/docs/{domain}/` | + +## Shared Test Utilities + +### IntegrationTestSupport + +Location: `bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java` + +**Fields:** +- `ObjectMapper mapper` - JSON serialization +- `MockMvc mockMvc` - legacy Spring MVC test API +- `MockMvcTester mockMvcTester` - modern Spring 6+ fluent API (prefer this) +- `TestAuthenticationSupport authSupport` - token generation +- `DataInitializer dataInitializer` - DB cleanup + +**Helper methods:** +- `getToken()` - default user token (3 overloads: no-arg, User, userId) +- `getTokenUserId()` - get userId from default token +- `extractData(MvcTestResult, Class)` - parse GlobalResponse.data into target type +- `extractData(MvcResult, Class)` - legacy MockMvc version +- `parseResponse(MvcTestResult)` - parse raw response to GlobalResponse +- `parseResponse(MvcResult)` - legacy version + +**Auto cleanup:** `@AfterEach` calls `dataInitializer.deleteAll()` which TRUNCATEs all tables except system tables (databasechangelog, flyway, schema_version). + +### TestContainersConfig + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` + +Containers (all with `@ServiceConnection` for auto-wiring): +- **MySQL 8.0.32** - `withReuse(true)`, DB name from `System.getProperty("testcontainers.db.name", "bottlenote")` +- **Redis 7.0.12** - `withReuse(true)` +- **MinIO** - S3-compatible storage with `AmazonS3` client bean + +Fake beans (`@Primary`, replaces real implementations in test context): +- `FakeWebhookRestTemplate` - captures HTTP calls instead of sending +- `FakeProfanityClient` - returns clean text without calling external API + +### TestAuthenticationSupport + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java` + +- `getFirstUser()` - first user from DB +- `getAccessToken()` - default access token +- `getRandomAccessToken()` - random user token +- `createToken(OauthRequest)` / `createToken(User)` - custom token generation +- `getDefaultUserId()` / `getUserId(String email)` - user ID lookup + +### DataInitializer + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java` + +- `deleteAll()` - TRUNCATE all user tables (dynamic table discovery via `SHOW TABLES`) +- `refreshCache()` - refresh table list for dynamically created tables +- `@Transactional(REQUIRES_NEW)` for isolation +- Filters: `databasechangelog*`, `flyway_*`, `schema_version`, `BATCH_*`, `QRTZ_*` + +## Test Data Patterns + +### TestFactory (Integration tests) + +Location: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/{Domain}TestFactory.java` + +TestFactory uses `EntityManager` + `@Transactional` (not JPA repositories). Method naming: `persist{Entity}()`. + +```java +@Component +public class RatingTestFactory { + @PersistenceContext + private EntityManager em; + + @Autowired + private AlcoholTestFactory alcoholTestFactory; + + private final AtomicInteger counter = new AtomicInteger(0); + + @Transactional + public Rating persistRating(Long alcoholId, Long userId) { + Rating rating = Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + em.persist(rating); + em.flush(); + return rating; + } +} +``` + +Key patterns: +- `AtomicInteger counter` for unique suffixes (names, emails) +- `persistAndFlush()` variants for immediate ID access +- Compose other factories: `AlcoholTestFactory` uses `RegionTestFactory`, `DistilleryTestFactory` + +### ObjectFixture (Unit tests) + +Location: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/{Domain}ObjectFixture.java` + +Static factory methods returning pre-configured domain objects. No DB, no Spring. + +```java +public class RatingObjectFixture { + public static Rating createRating(Long alcoholId, Long userId) { + return Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + } + + public static RatingRegisterRequest createRegisterRequest() { + return new RatingRegisterRequest(1L, 4.5); + } +} +``` + +Use ObjectFixture for unit tests, TestFactory for integration tests. + +## Existing Fake/InMemory Implementations + +Before creating a new Fake, check if one already exists. + +### InMemory Repositories +- `InMemoryRatingRepository`, `InMemoryReviewRepository`, `InMemoryLikesRepository` +- `InMemoryUserRepository`, `InMemoryUserQueryRepository` +- `InMemoryAlcoholQueryRepository`, `InMemoryFollowRepository` +- `InMemoryPicksRepository` (also `FakePicksRepository` in `picks/fake/`) +- Plus others for block, support, banner, etc. + +### Fake Services/Facades +- `FakeAlcoholFacade` - in-memory with `add()`, `remove()`, `clear()` +- `FakeUserFacade` - similar pattern +- `FakeHistoryEventPublisher` - captures published history events +- `FakeApplicationEventPublisher` - captures all Spring events (`getPublishedEvents()`, `getPublishedEventsOfType()`, `hasPublishedEventOfType()`, `clear()`) + +### Fake External Services +- `FakeWebhookRestTemplate` - captures HTTP calls (`getCallCount()`, `getLastRequestBody()`) +- `FakeProfanityClient` - returns input text as-is +- Fake JWT/BCrypt implementations for security testing diff --git a/.claude/skills/implement-test/references/test-patterns.md b/.claude/skills/implement-test/references/test-patterns.md new file mode 100644 index 000000000..539d1b67e --- /dev/null +++ b/.claude/skills/implement-test/references/test-patterns.md @@ -0,0 +1,230 @@ +# Test Patterns + +## 1. Unit Test - Fake/Stub Pattern (Preferred) + +Use InMemory implementations instead of mocks. Closer to real behavior and resilient to refactoring. + +### Fake Repository + +```java +// Location: {domain}/fixture/InMemory{Domain}Repository.java +public class InMemoryRatingRepository implements RatingRepository { + private final Map database = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Rating save(Rating rating) { + if (rating.getId() == null) { + ReflectionTestUtils.setField(rating, "id", idGenerator.getAndIncrement()); + } + database.put(rating.getId(), rating); + return rating; + } + + @Override + public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { + return database.values().stream() + .filter(r -> r.getAlcoholId().equals(alcoholId) && r.getUserId().equals(userId)) + .findFirst(); + } +} +``` + +### Fake Test + +```java +// Location: {domain}/service/Fake{Domain}ServiceTest.java +@Tag("unit") +@DisplayName("{Domain} service unit test") +class FakeRatingCommandServiceTest { + + private RatingCommandService sut; // system under test + private InMemoryRatingRepository ratingRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + ratingRepository = new InMemoryRatingRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + sut = new RatingCommandService(ratingRepository, eventPublisher, /* other fakes */); + } + + @Nested + @DisplayName("when registering a rating") + class RegisterRating { + + @Test + @DisplayName("valid request registers the rating") + void register_whenValidRequest_savesRating() { + // given + Long alcoholId = 1L; + Long userId = 1L; + RatingPoint point = RatingPoint.FIVE; + + // when + RatingRegisterResponse response = sut.register(alcoholId, userId, point); + + // then + assertThat(response).isNotNull(); + assertThat(ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId)).isPresent(); + } + + @Test + @DisplayName("event is published") + void register_publishesEvent() { + sut.register(1L, 1L, RatingPoint.FIVE); + + assertThat(eventPublisher.getPublishedEvents()) + .hasSize(1) + .first() + .isInstanceOf(RatingRegistryEvent.class); + } + } +} +``` + +## 2. Unit Test - Mockito (Last Resort Only) + +Mockito couples tests to implementation details and breaks on refactoring. +**Always ask the user before choosing Mockito over Fake/Stub.** + +```java +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RatingCommandServiceTest { + + @InjectMocks + private RatingCommandService sut; + + @Mock + private RatingRepository ratingRepository; + + @Mock + private AlcoholFacade alcoholFacade; + + @Test + @DisplayName("non-existent alcohol throws exception") + void register_whenAlcoholNotFound_throwsException() { + // given + given(alcoholFacade.existsByAlcoholId(anyLong())).willReturn(false); + + // when & then + assertThatThrownBy(() -> sut.register(1L, 1L, RatingPoint.FIVE)) + .isInstanceOf(RatingException.class); + } +} +``` + +## 3. Integration Test + +Full Spring context with TestContainers (real DB). +Read `test-infra.md` for IntegrationTestSupport details. + +```java +// Location: {domain}/integration/{Domain}IntegrationTest.java +@Tag("integration") +@DisplayName("{Domain} integration test") +class RatingIntegrationTest extends IntegrationTestSupport { + + @Autowired + private RatingTestFactory ratingTestFactory; + + @Test + @DisplayName("register a rating") + void registerRating() { + // given - TestFactory for data setup + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + String token = getToken(user); + + RatingRegisterRequest request = new RatingRegisterRequest(alcohol.getId(), 4.5); + + // when - MockMvcTester (modern API) + MvcTestResult result = mockMvcTester.post() + .uri("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .content(mapper.writeValueAsString(request)) + .exchange(); + + // then - helper methods + assertThat(result).hasStatusOk(); + GlobalResponse response = parseResponse(result); + assertThat(response.getSuccess()).isTrue(); + } +} +``` + +### Async Event Verification (Awaitility) + +```java +@Test +void register_triggersHistoryEvent() { + // ... perform action ... + + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List histories = historyRepository.findByUserId(userId); + assertThat(histories).hasSize(1); + }); +} +``` + +## 4. RestDocs Test + +API documentation with Spring REST Docs. Only implement when user explicitly requests. + +```java +// Location: app/docs/{domain}/Rest{Domain}ControllerDocsTest.java +class RestRatingControllerDocsTest extends AbstractRestDocs { + + @MockBean + private RatingCommandService commandService; + + @MockBean + private RatingQueryService queryService; + + @Override + protected Object initController() { + return new RatingController(commandService, queryService); + } + + @Test + @DisplayName("rating registration API docs") + void registerRating() throws Exception { + // given + given(commandService.register(anyLong(), anyLong(), any())) + .willReturn(new RatingRegisterResponse(1L)); + + mockSecurityContext(1L); // static mock for SecurityContextUtil + + // when & then + mockMvc.perform(post("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andDo(document("rating-register", + requestFields( + fieldWithPath("alcoholId").type(NUMBER).description("alcohol ID"), + fieldWithPath("rating").type(NUMBER).description("rating value") + ), + responseFields( + fieldWithPath("success").type(BOOLEAN).description("success"), + fieldWithPath("code").type(NUMBER).description("status code"), + fieldWithPath("data").type(OBJECT).description("response data"), + fieldWithPath("data.ratingId").type(NUMBER).description("rating ID"), + fieldWithPath("errors").type(ARRAY).description("error list"), + fieldWithPath("meta").type(OBJECT).description("meta info"), + fieldWithPath("meta.serverVersion").type(STRING).description("server version"), + fieldWithPath("meta.serverEncoding").type(STRING).description("encoding"), + fieldWithPath("meta.serverResponseTime").type(STRING).description("response time"), + fieldWithPath("meta.serverPathVersion").type(STRING).description("API version") + ) + )); + } +} +``` + +Note: RestDocs tests use `AbstractRestDocs` which sets up standalone MockMvc with `MockMvcBuilders.standaloneSetup()`, pretty-print configuration, and `GlobalExceptionHandler`. These tests use `@MockBean` for services - this is the one place where mocking is acceptable because we're testing documentation, not business logic. diff --git a/.claude/skills/product-api/references/test-patterns.md b/.claude/skills/product-api/references/test-patterns.md deleted file mode 100644 index f07ac271f..000000000 --- a/.claude/skills/product-api/references/test-patterns.md +++ /dev/null @@ -1,441 +0,0 @@ -# Test Patterns for Product API - -## Test Classification - -| Tag | Type | Base Class | Location | -|-----|------|------------|----------| -| `@Tag("unit")` | Unit test | None (plain JUnit) | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/service/` | -| `@Tag("integration")` | Integration test | `IntegrationTestSupport` | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/integration/` | -| (none) | RestDocs test | `AbstractRestDocs` | `bottlenote-product-api/src/test/java/app/docs/{domain}/` | - ---- - -## Test Infrastructure - -Read this section first — these are the shared utilities available across all test types. - -### IntegrationTestSupport - -Base class for all integration tests. Location: `bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java` - -**Provided fields:** -- `ObjectMapper mapper` - JSON serialization -- `MockMvc mockMvc` - legacy Spring MVC test API -- `MockMvcTester mockMvcTester` - modern Spring 6+ fluent API (prefer this) -- `TestAuthenticationSupport authSupport` - token generation -- `DataInitializer dataInitializer` - DB cleanup - -**Provided helper methods:** -- `getToken()` - default user token (3 overloads: no-arg, User, userId) -- `getTokenUserId()` - get userId from default token -- `extractData(MvcTestResult, Class)` - parse GlobalResponse.data into target type -- `extractData(MvcResult, Class)` - legacy MockMvc version -- `parseResponse(MvcTestResult)` - parse raw response to GlobalResponse -- `parseResponse(MvcResult)` - legacy version - -**Auto cleanup:** `@AfterEach` calls `dataInitializer.deleteAll()` which TRUNCATEs all tables except system tables (databasechangelog, flyway, schema_version). - -### TestContainersConfig - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` - -Containers (all with `@ServiceConnection` for auto-wiring): -- **MySQL 8.0.32** - `withReuse(true)`, DB name from `System.getProperty("testcontainers.db.name", "bottlenote")` -- **Redis 7.0.12** - `withReuse(true)` -- **MinIO** - S3-compatible storage with `AmazonS3` client bean - -Fake beans (`@Primary`, replaces real implementations in test context): -- `FakeWebhookRestTemplate` - captures HTTP calls instead of sending -- `FakeProfanityClient` - returns clean text without calling external API - -### TestAuthenticationSupport - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java` - -- `getFirstUser()` - first user from DB -- `getAccessToken()` - default access token -- `getRandomAccessToken()` - random user token -- `createToken(OauthRequest)` / `createToken(User)` - custom token generation -- `getDefaultUserId()` / `getUserId(String email)` - user ID lookup - -### DataInitializer - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java` - -- `deleteAll()` - TRUNCATE all user tables (dynamic table discovery via `SHOW TABLES`) -- `refreshCache()` - refresh table list for dynamically created tables -- `@Transactional(REQUIRES_NEW)` for isolation -- Filters: `databasechangelog*`, `flyway_*`, `schema_version`, `BATCH_*`, `QRTZ_*` - ---- - -## Test Data Patterns - -### TestFactory (Integration tests) - -Location: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/{Domain}TestFactory.java` - -TestFactory uses `EntityManager` + `@Transactional` (not JPA repositories). Method naming: `persist{Entity}()`. - -```java -@Component -public class RatingTestFactory { - @PersistenceContext - private EntityManager em; - - @Autowired - private AlcoholTestFactory alcoholTestFactory; - - private final AtomicInteger counter = new AtomicInteger(0); - - @Transactional - public Rating persistRating(Long alcoholId, Long userId) { - Rating rating = Rating.builder() - .alcoholId(alcoholId) - .userId(userId) - .ratingPoint(RatingPoint.FOUR) - .build(); - em.persist(rating); - em.flush(); - return rating; - } -} -``` - -Key patterns: -- `AtomicInteger counter` for unique suffixes (names, emails) -- `persistAndFlush()` variants for immediate ID access -- Compose other factories: `AlcoholTestFactory` uses `RegionTestFactory`, `DistilleryTestFactory` - -### ObjectFixture (Unit tests) - -Location: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/{Domain}ObjectFixture.java` - -Static factory methods returning pre-configured domain objects. No DB, no Spring. - -```java -public class RatingObjectFixture { - public static Rating createRating(Long alcoholId, Long userId) { - return Rating.builder() - .alcoholId(alcoholId) - .userId(userId) - .ratingPoint(RatingPoint.FOUR) - .build(); - } - - public static RatingRegisterRequest createRegisterRequest() { - return new RatingRegisterRequest(1L, 4.5); - } -} -``` - -Use ObjectFixture for unit tests, TestFactory for integration tests. - ---- - -## Existing Fake/InMemory Implementations - -Before creating a new Fake, check if one already exists. - -### InMemory Repositories -- `InMemoryRatingRepository`, `InMemoryReviewRepository`, `InMemoryLikesRepository` -- `InMemoryUserRepository`, `InMemoryUserQueryRepository` -- `InMemoryAlcoholQueryRepository`, `InMemoryFollowRepository` -- `InMemoryPicksRepository` (also `FakePicksRepository` in `picks/fake/`) -- Plus others for block, support, banner, etc. - -### Fake Services/Facades -- `FakeAlcoholFacade` - in-memory with `add()`, `remove()`, `clear()` -- `FakeUserFacade` - similar pattern -- `FakeHistoryEventPublisher` - captures published history events -- `FakeApplicationEventPublisher` - captures all Spring events (`getPublishedEvents()`, `getPublishedEventsOfType()`, `hasPublishedEventOfType()`, `clear()`) - -### Fake External Services -- `FakeWebhookRestTemplate` - captures HTTP calls (`getCallCount()`, `getLastRequestBody()`) -- `FakeProfanityClient` - returns input text as-is -- Fake JWT/BCrypt implementations for security testing - ---- - -## 1. Unit Test - Fake/Stub Pattern (Preferred) - -Mock 대신 InMemory 구현체를 사용하는 패턴. 실제 동작에 가깝고 리팩토링에 강하다. - -### Fake Repository - -```java -// Location: {domain}/fixture/InMemory{Domain}Repository.java -public class InMemoryRatingRepository implements RatingRepository { - private final Map database = new HashMap<>(); - private final AtomicLong idGenerator = new AtomicLong(1); - - @Override - public Rating save(Rating rating) { - if (rating.getId() == null) { - ReflectionTestUtils.setField(rating, "id", idGenerator.getAndIncrement()); - } - database.put(rating.getId(), rating); - return rating; - } - - @Override - public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { - return database.values().stream() - .filter(r -> r.getAlcoholId().equals(alcoholId) && r.getUserId().equals(userId)) - .findFirst(); - } -} -``` - -### Fake Test - -```java -// Location: {domain}/service/Fake{Domain}ServiceTest.java -@Tag("unit") -@DisplayName("{Domain} 서비스 단위 테스트") -class FakeRatingCommandServiceTest { - - private RatingCommandService sut; // system under test - private InMemoryRatingRepository ratingRepository; - private FakeApplicationEventPublisher eventPublisher; - - @BeforeEach - void setUp() { - ratingRepository = new InMemoryRatingRepository(); - eventPublisher = new FakeApplicationEventPublisher(); - sut = new RatingCommandService(ratingRepository, eventPublisher, /* other fakes */); - } - - @Nested - @DisplayName("평점 등록 시") - class RegisterRating { - - @Test - @DisplayName("유효한 요청이면 평점을 등록할 수 있다") - void register_whenValidRequest_savesRating() { - // given - Long alcoholId = 1L; - Long userId = 1L; - RatingPoint point = RatingPoint.FIVE; - - // when - RatingRegisterResponse response = sut.register(alcoholId, userId, point); - - // then - assertThat(response).isNotNull(); - assertThat(ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId)).isPresent(); - } - - @Test - @DisplayName("이벤트가 발행된다") - void register_publishesEvent() { - sut.register(1L, 1L, RatingPoint.FIVE); - - assertThat(eventPublisher.getPublishedEvents()) - .hasSize(1) - .first() - .isInstanceOf(RatingRegistryEvent.class); - } - } -} -``` - -## 2. Unit Test - Mockito (Last Resort Only) - -Mockito는 최후의 수단이다. Fake/Stub으로 해결할 수 없는 경우에만 사용한다. -Mock은 구현 세부사항에 결합되어 리팩토링 시 깨지기 쉽고, 실제 동작을 검증하지 못한다. - -**Mockito 사용 전 반드시 사용자에게 먼저 확인:** -- Fake 구현의 공수가 과도하게 큰 경우 (외부 시스템 연동, 복잡한 인프라 의존 등) -- 사용자에게 "Fake 구현 공수가 크니 Mock으로 타협할까요?" 라고 대화를 먼저 시작할 것 -- 사용자 동의 없이 Mockito를 선택하지 말 것 - -```java -@Tag("unit") -@ExtendWith(MockitoExtension.class) -class RatingCommandServiceTest { - - @InjectMocks - private RatingCommandService sut; - - @Mock - private RatingRepository ratingRepository; - - @Mock - private AlcoholFacade alcoholFacade; - - @Test - @DisplayName("존재하지 않는 주류에 평점을 등록하면 예외가 발생한다") - void register_whenAlcoholNotFound_throwsException() { - // given - given(alcoholFacade.existsByAlcoholId(anyLong())).willReturn(false); - - // when & then - assertThatThrownBy(() -> sut.register(1L, 1L, RatingPoint.FIVE)) - .isInstanceOf(RatingException.class); - } -} -``` - -## 3. Integration Test - -Full Spring context with TestContainers (real DB). - -```java -// Location: {domain}/integration/{Domain}IntegrationTest.java -@Tag("integration") -@DisplayName("{Domain} 통합 테스트") -class RatingIntegrationTest extends IntegrationTestSupport { - - @Autowired - private RatingTestFactory ratingTestFactory; - - @Test - @DisplayName("평점을 등록할 수 있다") - void registerRating() { - // given - TestFactory로 데이터 생성 - User user = userTestFactory.persistUser(); - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); - String token = getToken(user); - - RatingRegisterRequest request = new RatingRegisterRequest(alcohol.getId(), 4.5); - - // when - MockMvcTester (modern API) - MvcTestResult result = mockMvcTester.post() - .uri("/api/v1/ratings") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .content(mapper.writeValueAsString(request)) - .exchange(); - - // then - helper methods - assertThat(result).hasStatusOk(); - GlobalResponse response = parseResponse(result); - assertThat(response.getSuccess()).isTrue(); - } -} -``` - -### Async Event Wait (Awaitility) - -```java -@Test -void register_triggersHistoryEvent() { - // ... perform action ... - - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted(() -> { - List histories = historyRepository.findByUserId(userId); - assertThat(histories).hasSize(1); - }); -} -``` - -## 4. RestDocs Test - -API documentation with Spring REST Docs. - -```java -// Location: app/docs/{domain}/Rest{Domain}ControllerDocsTest.java -class RestRatingControllerDocsTest extends AbstractRestDocs { - - @MockBean - private RatingCommandService commandService; - - @MockBean - private RatingQueryService queryService; - - @Override - protected Object initController() { - return new RatingController(commandService, queryService); - } - - @Test - @DisplayName("평점 등록 API 문서화") - void registerRating() throws Exception { - // given - given(commandService.register(anyLong(), anyLong(), any())) - .willReturn(new RatingRegisterResponse(1L)); - - mockSecurityContext(1L); // static mock for SecurityContextUtil - - // when & then - mockMvc.perform(post("/api/v1/ratings") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(document("rating-register", - requestFields( - fieldWithPath("alcoholId").type(NUMBER).description("주류 ID"), - fieldWithPath("rating").type(NUMBER).description("평점") - ), - responseFields( - fieldWithPath("success").type(BOOLEAN).description("성공 여부"), - fieldWithPath("code").type(NUMBER).description("상태 코드"), - fieldWithPath("data").type(OBJECT).description("응답 데이터"), - fieldWithPath("data.ratingId").type(NUMBER).description("평점 ID"), - fieldWithPath("errors").type(ARRAY).description("에러 목록"), - fieldWithPath("meta").type(OBJECT).description("메타 정보"), - fieldWithPath("meta.serverVersion").type(STRING).description("서버 버전"), - fieldWithPath("meta.serverEncoding").type(STRING).description("인코딩"), - fieldWithPath("meta.serverResponseTime").type(STRING).description("응답 시간"), - fieldWithPath("meta.serverPathVersion").type(STRING).description("API 버전") - ) - )); - } -} -``` - -Note: RestDocs tests use `AbstractRestDocs` which sets up standalone MockMvc with `MockMvcBuilders.standaloneSetup()`, pretty-print configuration, and `GlobalExceptionHandler`. These tests use `@MockBean` for services — this is the one place where mocking is acceptable because we're testing documentation, not business logic. - ---- - ---- - -## What to Implement - -기능 구현 시 어떤 테스트를 만들어야 하는지 기준. - -### Required - -| 구현 대상 | 테스트 타입 | 태그 | 위치 | -|-----------|-----------|------|------| -| Service (비즈니스 로직) | Unit (Fake/Stub) | `@Tag("unit")` | `{domain}/service/Fake{Domain}ServiceTest.java` | -| Controller (API 엔드포인트) | Integration | `@Tag("integration")` | `{domain}/integration/{Domain}IntegrationTest.java` | - -- Unit 테스트는 **반드시** Fake/Stub 패턴으로 작성 -- Integration 테스트는 **반드시** `IntegrationTestSupport` 상속 - -### Optional (사용자 요청 시) - -| 구현 대상 | 테스트 타입 | 태그 | 위치 | -|-----------|-----------|------|------| -| API 문서화 | RestDocs | `@Tag("restdocs")` | `app/docs/{domain}/Rest{Domain}ControllerDocsTest.java` | - -RestDocs는 사용자가 API 문서화를 요청한 경우에만 작성한다. - -### Tag Reference - -새 테스트 작성 시 반드시 아래 태그를 사용: - -```java -@Tag("unit") // 단위 테스트 → ./gradlew unit_test -@Tag("integration") // product 통합 테스트 → ./gradlew integration_test -@Tag("admin_integration") // admin 통합 테스트 → ./gradlew admin_integration_test -@Tag("restdocs") // API 문서화 테스트 → ./gradlew restDocsTest -``` - -### Verify Skill 연계 - -테스트 작성 후 `/verify` 스킬로 검증: -- **구현 중**: `/verify quick` (컴파일 + 아키텍처 규칙 통과 확인) -- **테스트 작성 완료**: `/verify standard` (컴파일 + unit test + build) -- **push 직전**: `/verify full` (전체 CI - integration 포함) - -## Test Naming Convention - -- Class: `Fake{Feature}ServiceTest`, `{Feature}IntegrationTest`, `Rest{Domain}ControllerDocsTest` -- Method: `{action}_{scenario}_{expectedResult}` or Korean `@DisplayName` -- DisplayName format: `~할 때 ~한다`, `~하면 ~할 수 있다` From 1db651a83aee1f309a453a1d0e3692f69d8d0a1c Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 20:55:43 +0900 Subject: [PATCH 24/31] feat: add agentic skill system with 6 new skills - Add /define skill for requirements clarification - Add /plan skill for task breakdown with plan document lifecycle - Add /implement skill unifying product-api and admin-api with Task-Slice-Commit cycle - Add /test skill with enhanced test type timing and pattern selection - Add /debug skill with 6-step systematic debugging process - Add /self-review skill with 5-axis pre-commit quality gate - Restructure CLAUDE.md as project overview, move detailed rules to skill references - Add design document for the skill improvement plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/debug/SKILL.md | 201 +++++ .claude/skills/define/SKILL.md | 154 ++++ .claude/skills/implement/SKILL.md | 211 +++++ .../implement/references/admin-patterns.md | 123 +++ .../implement/references/mono-patterns.md | 246 ++++++ .../implement/references/product-patterns.md | 65 ++ .claude/skills/plan/SKILL.md | 189 ++++ .claude/skills/self-review/SKILL.md | 158 ++++ .claude/skills/test/SKILL.md | 221 +++++ .claude/skills/test/references/test-infra.md | 149 ++++ .../skills/test/references/test-patterns.md | 230 +++++ CLAUDE.md | 145 +--- plan/claude-ai-harness-improvement.md | 808 +++++++++++------- 13 files changed, 2464 insertions(+), 436 deletions(-) create mode 100644 .claude/skills/debug/SKILL.md create mode 100644 .claude/skills/define/SKILL.md create mode 100644 .claude/skills/implement/SKILL.md create mode 100644 .claude/skills/implement/references/admin-patterns.md create mode 100644 .claude/skills/implement/references/mono-patterns.md create mode 100644 .claude/skills/implement/references/product-patterns.md create mode 100644 .claude/skills/plan/SKILL.md create mode 100644 .claude/skills/self-review/SKILL.md create mode 100644 .claude/skills/test/SKILL.md create mode 100644 .claude/skills/test/references/test-infra.md create mode 100644 .claude/skills/test/references/test-patterns.md diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md new file mode 100644 index 000000000..788117cd3 --- /dev/null +++ b/.claude/skills/debug/SKILL.md @@ -0,0 +1,201 @@ +--- +name: debug +description: | + Systematic root-cause debugging for build failures, test failures, and runtime errors. + Trigger: "/debug", or when the user says "에러 났어", "테스트 실패", "빌드 안 돼", "왜 안 되지", "debug this". + Follows a structured 6-step process: STOP, REPRODUCE, LOCALIZE, FIX, GUARD, VERIFY. + Use when anything unexpected happens — do not guess at fixes. +argument-hint: "[error description or test name]" +--- + +# Debugging and Error Recovery + +## Overview + +When something breaks, stop adding features, preserve evidence, and follow a structured process to find and fix the root cause. Guessing wastes time. This skill works for build errors, test failures, runtime bugs, and unexpected behavior in the bottle-note-api-server project. + +## When to Use + +- Build fails (`compileJava`, `compileKotlin`, `spotlessApply`) +- Tests fail (`unit_test`, `integration_test`, `check_rule_test`, `admin_integration_test`) +- Runtime behavior does not match expectations +- An error appears in logs or console +- Something worked before and stopped working + +## When NOT to Use + +- Implementing new features (use `/implement`) +- Writing new tests (use `/test`) +- Code cleanup or refactoring (use `/self-review` for review, `/implement` for changes) + +## Process + +### Step 1: STOP + +Stop all other changes immediately. + +- Do NOT push past a failing test to work on the next feature +- Preserve the error output — copy the full message before doing anything +- If you have uncommitted work in progress, stash it: `git stash` +- Read the COMPLETE error message before forming any hypothesis + +### Step 2: REPRODUCE + +Make the failure happen reliably. If you cannot reproduce it, you cannot fix it with confidence. + +``` +Can you reproduce the failure? +├── YES -> Proceed to Step 3 +└── NO + ├── Check environment differences (Docker running? submodule initialized?) + ├── Run in isolation (single test, clean build) + └── If truly non-reproducible, document conditions and monitor +``` + +**Common reproduction commands:** +```bash +# Specific test +./gradlew :bottlenote-product-api:test --tests "app.bottlenote.{domain}.{TestClass}.{testMethod}" + +# Test by tag +./gradlew unit_test +./gradlew integration_test +./gradlew admin_integration_test +./gradlew check_rule_test + +# Full clean build +./gradlew clean build -x test -x asciidoctor + +# Compile only +./gradlew compileJava compileTestJava +./gradlew :bottlenote-admin-api:compileKotlin :bottlenote-admin-api:compileTestKotlin +``` + +### Step 3: LOCALIZE + +Narrow down WHERE the failure happens. Use the project-specific triage tree: + +``` +Build failure: +├── Java compile error +│ ├── In bottlenote-mono -> check domain/service/repository code +│ ├── In bottlenote-product-api -> check controller code +│ └── In test source -> check test fixtures, InMemory implementations +├── Kotlin compile error +│ └── In bottlenote-admin-api -> check Kotlin controller/test code +├── Spotless format error +│ └── Run: ./gradlew spotlessApply (auto-fixes formatting) +└── Dependency resolution error + └── Check gradle/libs.versions.toml for version conflicts + +Test failure: +├── @Tag("unit") +│ ├── Fake/InMemory implementation out of sync with domain repo interface? +│ ├── Service logic changed but test not updated? +│ └── New dependency not wired in @BeforeEach setup? +├── @Tag("integration") +│ ├── Docker running? (TestContainers requires Docker) +│ ├── Database schema changed? (check Liquibase changelogs) +│ ├── Test data setup missing? (check TestFactory) +│ └── Auth token issue? (check TestAuthenticationSupport) +├── @Tag("rule") +│ ├── Package dependency violation? (check ArchUnit rules) +│ ├── New class in wrong package? +│ └── Circular dependency introduced? +└── @Tag("admin_integration") + ├── Admin auth setup correct? + ├── context-path /admin/api/v1 accounted for in test? + └── Kotlin-Java interop issue? +``` + +**For stack traces:** read bottom-up, find the first line referencing `app.bottlenote.*`. + +### Step 4: FIX + +Fix the ROOT CAUSE, not the symptom. + +``` +Symptom: "Test expects 3 items but gets 2" + +Symptom fix (bad): + -> Change assertion to expect 2 + +Root cause fix (good): + -> The query has a WHERE clause that filters out soft-deleted items + -> Fix the test data setup to not include soft-deleted items +``` + +Rules: +- One change at a time — compile after each change +- If fix requires more than 5 files, reconsider whether the diagnosis is correct +- Do NOT suppress errors (`@Disabled`, empty catch blocks, `@SuppressWarnings`) +- Do NOT delete or skip failing tests + +### Step 5: GUARD + +Write a regression test that would have caught this bug. + +- Use `@DisplayName` in Korean describing the bug scenario +- The test should FAIL without the fix and PASS with it +- If the fix changed a domain Repository interface, update the corresponding `InMemory{Domain}Repository` +- If the fix changed a Facade interface, update the corresponding `Fake{Domain}Facade` + +### Step 6: VERIFY + +Run verification to confirm the fix and check for regressions. + +| Original failure | Minimum verification | +|-----------------|---------------------| +| Compile error | `./gradlew compileJava compileTestJava` | +| Unit test | `./gradlew unit_test` | +| Integration test | `./gradlew integration_test` (requires Docker) | +| Architecture rule | `./gradlew check_rule_test` | +| Admin test | `./gradlew admin_integration_test` | +| Unknown/broad | `/verify standard` or `/verify full` | + +## Quick Reference: Diagnostic Commands + +| Situation | Command | +|-----------|---------| +| What changed recently | `git log --oneline -10` | +| What files are modified | `git status` | +| Diff of uncommitted changes | `git diff` | +| Find which commit broke it | `git bisect start && git bisect bad HEAD && git bisect good ` | +| Check Java compile | `./gradlew compileJava compileTestJava` | +| Check Kotlin compile | `./gradlew :bottlenote-admin-api:compileKotlin` | +| Auto-fix formatting | `./gradlew spotlessApply` | +| Run single test class | `./gradlew test --tests "app.bottlenote.{domain}.{TestClass}"` | +| Check dependency versions | `cat gradle/libs.versions.toml` | +| Check Docker status | `docker info` | + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "I know what the bug is, I'll just fix it" | You might be right 70% of the time. The other 30% costs hours. Reproduce first. | +| "The failing test is probably wrong" | Verify that assumption. If the test is wrong, fix the test. Do not skip it. | +| "Let me just revert and redo everything" | Reverting destroys diagnostic information. Understand WHAT broke before reverting. | +| "This is a flaky test, ignore it" | Flaky tests mask real bugs. Fix the flakiness or understand why it is intermittent. | +| "I'll fix it in the next commit" | Fix it now. The next commit will introduce new issues on top of this one. | + +## Red Flags + +- Changing more than 5 files to fix a "simple" bug (diagnosis is likely wrong) +- Fixing without reproducing first +- Multiple stacked fixes without verifying between each one +- Suppressing errors instead of fixing root cause (`@Disabled`, empty catch, lint-disable) +- Changing test assertions to match wrong behavior +- "It works now" without understanding what changed +- No regression test added after a bug fix + +## Verification + +After fixing a bug: + +- [ ] Root cause identified and understood (not just symptom) +- [ ] Fix addresses the root cause specifically +- [ ] Regression test exists that fails without the fix +- [ ] All existing tests pass +- [ ] Build succeeds +- [ ] InMemory/Fake implementations updated if interfaces changed +- [ ] Original failure scenario verified end-to-end diff --git a/.claude/skills/define/SKILL.md b/.claude/skills/define/SKILL.md new file mode 100644 index 000000000..146dc8c16 --- /dev/null +++ b/.claude/skills/define/SKILL.md @@ -0,0 +1,154 @@ +--- +name: define +description: | + Clarifies requirements before any code is written. Creates a plan document with assumptions, success criteria, and impact scope. + Trigger: "/define", or when the user says "이거 구현해줘", "기능 추가", "요구사항 정리", "define requirements". + Use when starting a new feature, when requirements are vague, or when the scope of a change is unclear. + Do NOT write code during this skill — the output is a plan document, not implementation. +argument-hint: "[feature description]" +--- + +# Define Requirements + +## Overview + +Write a structured specification before writing any code. The plan document is the shared source of truth — it defines what we are building, why, and how we will know it is done. Code without a spec is guessing. + +This skill creates `plan/{feature-name}.md` with an Overview section. The `/plan` skill later adds Tasks to the same document. + +## When to Use + +- Starting a new feature or significant change +- Requirements are ambiguous or incomplete +- The change touches 3+ files or multiple modules +- The user gives a vague request ("이거 구현해줘", "추가해줘") + +## When NOT to Use + +- Bug fixes with clear reproduction (use `/debug`) +- Single-file changes with obvious scope +- Requirements are already documented in a plan file +- Test-only work (use `/test`) + +## Process + +### Step 1: Parse Request + +Identify what the user wants. Do NOT assume scope. + +- What domain is involved? (alcohols, rating, review, support, etc.) +- Which module? (product-api, admin-api, or both?) +- What is the expected user-facing behavior? +- Are there related features already implemented? + +If anything is unclear, ask before proceeding. Do NOT fill in ambiguous requirements silently. + +### Step 2: Surface Assumptions + +List every assumption explicitly. Each assumption is something that could be wrong. + +``` +ASSUMPTIONS: +1. This feature is for product-api (not admin-api) +2. Authentication is required (not a public endpoint) +3. The alcohol entity already exists and does not need schema changes +4. Pagination uses cursor-based approach (project default for product) +-> Confirm or correct these before I proceed. +``` + +Do NOT proceed without user confirmation on assumptions. + +### Step 3: Define Success Criteria + +Each criterion must be specific and testable. Translate vague requirements into concrete conditions. + +``` +REQUIREMENT: "평점 통계 기능 추가" + +SUCCESS CRITERIA: +- GET /api/v1/ratings/statistics/{alcoholId} returns average rating, count, and distribution +- Response includes rating distribution as a map (e.g., {FIVE: 12, FOUR: 8, ...}) +- Unauthenticated users can access (read-only endpoint) +- Response time < 500ms for alcohols with 1000+ ratings +-> Are these the right targets? +``` + +### Step 4: Analyze Impact Scope + +Check which modules and components are affected: + +- **Modules**: Which of mono, product-api, admin-api are involved? +- **Domains**: Does this touch multiple domains? (If yes, Facade needed) +- **Entities**: Any schema changes? (Liquibase migration needed) +- **Events**: New domain events? Existing event listeners affected? +- **Cache**: Does this data need caching? Existing cache invalidation affected? +- **Tests**: Which test types will be needed? (unit, integration, RestDocs) + +### Step 5: Create Plan Document + +Create `plan/{feature-name}.md` in Korean with the following structure: + +```markdown +# Plan: [기능명] + +## Overview +[무엇을 왜 만드는지] + +### Assumptions +- [가정 1] +- [가정 2] + +### Success Criteria +- [성공 기준 1 - 구체적, 테스트 가능] +- [성공 기준 2] + +### Impact Scope +- [영향받는 모듈/파일 목록] +``` + +This document will be extended by `/plan` (Tasks section) and `/implement` (Progress Log). + +### Step 6: User Approval Gate + +Present the complete Overview to the user. Do NOT proceed to `/plan` or `/implement` without explicit approval. + +``` +Plan document created: plan/{feature-name}.md + +Summary: +- Assumptions: [count] items listed +- Success criteria: [count] conditions defined +- Impact: [modules affected] + +Approve to proceed to /plan for task breakdown? +``` + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "This is simple, I don't need a spec" | Simple tasks still need acceptance criteria. A 2-line spec is fine. | +| "I'll figure it out while coding" | That is how you end up with rework. 15 minutes of spec saves 3 hours of wrong implementation. | +| "Requirements will change anyway" | That is why the spec is a living document. Having one that changes is better than having none. | +| "The user knows what they want" | Even clear requests have implicit assumptions. The spec surfaces those. | +| "I can just start with /implement" | Without defined success criteria, how will you know when you are done? | + +## Red Flags + +- Jumping to code without user approval on assumptions +- Assumptions not listed explicitly +- Success criteria that are not testable ("make it better", "improve performance") +- Missing impact analysis (especially cross-domain Facade needs) +- Proceeding to `/plan` without user approval on the Overview +- Creating multiple plan documents for a single feature + +## Verification + +Before proceeding to `/plan`: + +- [ ] Plan document exists at `plan/{feature-name}.md` +- [ ] Assumptions are listed and confirmed by user +- [ ] Success criteria are specific and testable +- [ ] Impact scope identifies affected modules, domains, and test types +- [ ] User has explicitly approved the Overview +- [ ] Document is written in Korean (plan documents use Korean) diff --git a/.claude/skills/implement/SKILL.md b/.claude/skills/implement/SKILL.md new file mode 100644 index 000000000..cb4346099 --- /dev/null +++ b/.claude/skills/implement/SKILL.md @@ -0,0 +1,211 @@ +--- +name: implement +description: | + Incremental feature implementation for product-api and admin-api modules. + Trigger: "/implement", or when the user says "API 추가", "엔드포인트 구현", "기능 구현", "feature implementation", "기능 개발". + Guides through the implementation flow: mono module (domain/service) -> API module (controller). + Supports both product-api (Java) and admin-api (Kotlin) via module argument. + For test implementation, use /test after this skill completes. +argument-hint: "[domain] [product|admin] [crud|search|action]" +--- + +# Incremental Implementation + +## Overview + +Build features in thin vertical slices — implement one piece, verify it compiles, then expand. Each Task is a committable unit; each Slice within a Task is a compile-check unit. This skill unifies product-api (Java) and admin-api (Kotlin) implementation through a shared workflow with module-specific branching. + +All business logic lives in `bottlenote-mono`. API modules (`bottlenote-product-api`, `bottlenote-admin-api`) contain only thin controllers that delegate to mono services/facades. + +## When to Use + +- Implementing any new feature or endpoint +- Adding CRUD operations to an existing domain +- Extending an existing domain with new service methods +- Any multi-file implementation work in product-api or admin-api + +## When NOT to Use + +- Bug fixing with clear reproduction (use `/debug`) +- Test-only work (use `/test`) +- Requirements unclear or ambiguous (use `/define` first) +- Single-file config changes or documentation updates + +## Argument Parsing + +Parse `$ARGUMENTS` to identify: +- **domain**: target domain (e.g., `alcohols`, `rating`, `review`, `support`) +- **module**: `product` (default) or `admin` +- **work type**: `crud`, `search`, `action` (informational, guides exploration scope) + +## Process + +### Phase 0: Explore + +Before writing any code, understand what already exists and what will be affected. + +**Codebase scan:** +1. Check if the domain exists in mono: `bottlenote-mono/src/main/java/app/bottlenote/{domain}/` +2. Check existing services, facades, and repositories +3. Check if the target API module already has controllers for this domain +4. Identify reusable code vs. what needs to be created + +**Impact analysis:** +5. **Events** — related domain events (publish/subscribe), whether new events are needed +6. **Transactions** — propagation policy when calling across Facades, need for `@Async` separation +7. **Ripple scope** — files affected if Repository/Facade interfaces change (other services, InMemory implementations, tests) +8. **Cache** — whether the target data is cached (`@Cacheable`, Caffeine, Redis), invalidation strategy needed +9. **Schema** — whether Entity changes require a Liquibase migration + +Report both findings and impact to the user before proceeding. + +### Phase 1: Mono Module (Domain & Business Logic) + +All business logic belongs in `bottlenote-mono`. Read `references/mono-patterns.md` for detailed patterns. + +**Order of implementation:** +1. **Entity/Domain** (if new domain) — `{domain}/domain/` +2. **Repository** (3-tier) — Domain repo -> JPA repo -> QueryDSL (if needed) +3. **DTO** — Request/Response records in `{domain}/dto/request/`, `{domain}/dto/response/` +4. **Exception** — `{domain}/exception/{Domain}Exception.java` + `{Domain}ExceptionCode.java` +5. **Service** — `{Domain}Service` (single service is the default; Command/Query split is optional) +6. **Facade** (when cross-domain access is needed) — `{Domain}Facade` interface + `Default{Domain}Facade` + +**Module-specific service naming:** +- **product**: `{Domain}Service` or `{Domain}CommandService` / `{Domain}QueryService` +- **admin**: `Admin{Domain}Service` (separate service with `adminId` parameter pattern) + +### Phase 2: API Controller + +Read the appropriate reference for your target module: +- **product**: `references/product-patterns.md` +- **admin**: `references/admin-patterns.md` + +**Product (Java):** +- Path: `@RequestMapping("/api/v1/{plural-resource}")` +- Auth required: `SecurityContextUtil.getUserIdByContext().orElseThrow(...)` +- Auth optional (read): `.orElse(-1L)` +- Response: `GlobalResponse.ok(response)` or `GlobalResponse.ok(response, metaInfos)` +- Pagination: `CursorPageable` + `PageResponse` + +**Admin (Kotlin):** +- Context path: `/admin/api/v1` (configured in application) +- Path: `@RequestMapping("/{plural-resource}")` +- Auth: `SecurityContextUtil.getAdminUserIdByContext()` +- Response: `GlobalResponse.ok(service.method())` or `ResponseEntity.ok(service.search(request))` +- Pagination: Offset-based (`page`, `size`) + +### Phase 3: Task-Slice-Commit Cycle + +Each Task from the `/plan` is implemented through Slices: + +``` +Task = Commit unit (logical goal agreed with user) +Slice = Execution unit (compile check before exceeding ~100 lines) +``` + +**Verification levels:** + +| Timing | Level | What to run | +|--------|-------|-------------| +| After each Slice | Compile only | `./gradlew compileJava compileTestJava` (+ `compileKotlin` for admin) | +| After Task completion | Self-review + unit tests | `/self-review` -> `./gradlew unit_test check_rule_test` | +| After all Tasks complete | Integration tests | `/verify full` | + +**Commit message format** (Task = title, Slices = bullets): +``` +feat: rating 통계 API Service 구현 + +- RatingStatisticsResponse DTO 작성 +- RatingRepository에 통계 조회 메서드 추가 +- RatingService.getStatistics() 구현 +- AlcoholFacade 연동 +``` + +**Cycle per Task:** +1. Implement Slice -> compile check -> pass? continue : fix +2. Repeat until all Slices in the Task are done +3. Run `/self-review` on the Task's changes +4. Run `./gradlew unit_test check_rule_test` +5. Commit with descriptive message +6. Update plan document (check off Task, add to Progress Log) +7. Move to next Task + +### Phase 4: Final Verification + +After all Tasks are committed: + +| Timing | Command | Purpose | +|--------|---------|---------| +| Implementation done | `/verify standard` | Compile + unit + build | +| Before push/PR | `/verify full` | Includes integration tests | + +Then use `/test` if integration tests need to be written. + +## Endpoint Design + +| HTTP Method | Purpose | URL Pattern (product) | URL Pattern (admin) | +|-------------|---------|----------------------|---------------------| +| GET | List | `/api/v1/{resources}` | `/{resources}` | +| GET | Detail | `/api/v1/{resources}/{id}` | `/{resources}/{id}` | +| POST | Create | `/api/v1/{resources}` | `/{resources}` | +| PUT | Full update | `/api/v1/{resources}/{id}` | `/{resources}/{id}` | +| PATCH | Partial | `/api/v1/{resources}/{id}` | `/{resources}/{id}` | +| DELETE | Delete | `/api/v1/{resources}/{id}` | `/{resources}/{id}` | + +## Package Structure + +``` +bottlenote-mono/src/main/java/app/bottlenote/{domain}/ +├── constant/ # Enums, constants +├── domain/ # Entities, DomainRepository interface (@DomainRepository) +├── dto/ +│ ├── request/ # Request records (@Valid) +│ ├── response/ # Response records +│ └── dsl/ # QueryDSL criteria +├── event/ # Domain events, @DomainEventListener +├── exception/ # {Domain}Exception + {Domain}ExceptionCode +├── facade/ # {Domain}Facade interface +├── repository/ # Jpa{Domain}Repository (@JpaRepositoryImpl), Custom repos +└── service/ # {Domain}Service (@Service), Default{Domain}Facade (@FacadeService) + +bottlenote-product-api/src/main/java/app/bottlenote/{domain}/ +└── controller/ # {Domain}Controller (thin, delegates to mono) + +bottlenote-admin-api/src/main/kotlin/app/bottlenote/{domain}/ +└── presentation/ # Admin{Domain}Controller.kt (thin, delegates to mono) +``` + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "I'll test it all at the end" | Bugs compound. A bug in Slice 1 makes Slices 2-5 wrong. Compile-check each Slice. | +| "It's faster to do it all at once" | It feels faster until something breaks and you cannot find which of 500 changed lines caused it. | +| "I don't need a Facade for this" | If you are accessing another domain's Repository or Service directly, you need a Facade. Domain boundaries exist for a reason. | +| "This refactor is small enough to include" | Refactors mixed with features make both harder to review and debug. Separate them. | +| "The controller can handle this logic" | Controllers are thin. Business logic belongs in mono services. Always. | +| "I'll skip self-review, the code is straightforward" | Straightforward code still needs architecture and security checks. Run `/self-review`. | + +## Red Flags + +- More than 100 lines written without a compile check +- Cross-domain Repository or Service injected directly (bypassing Facade) +- Controller containing business logic instead of delegating to service +- Repository interface changed but InMemory test implementation not updated +- No error handling for domain-specific exceptions +- Skipping Phase 0 (Explore) and jumping straight to coding +- Multiple unrelated changes in a single Task +- Task completed without running `/self-review` + +## Verification + +After completing all Tasks for a feature: + +- [ ] Each Task was individually reviewed (`/self-review`) and committed +- [ ] All Facade boundaries respected (no cross-domain direct access) +- [ ] Repository 3-Tier pattern followed for new repositories +- [ ] Unit tests pass: `./gradlew unit_test` +- [ ] Architecture rules pass: `./gradlew check_rule_test` +- [ ] Build succeeds: `./gradlew build -x test -x asciidoctor` +- [ ] Plan document updated (all Tasks checked, Progress Log filled) diff --git a/.claude/skills/implement/references/admin-patterns.md b/.claude/skills/implement/references/admin-patterns.md new file mode 100644 index 000000000..2d4954ffb --- /dev/null +++ b/.claude/skills/implement/references/admin-patterns.md @@ -0,0 +1,123 @@ +# Admin API Controller Patterns + +## Architecture + +``` +admin-api (Kotlin) -> mono (Java) +├── Controller (presentation) ─┬-> Service (business logic) +│ ├-> Repository (JPA + QueryDSL) +│ └-> DTO (Request/Response) +``` + +**Core principles:** +- admin-api handles presentation layer only (Kotlin) +- Business logic and DTOs are written in mono module (Java) +- admin-api depends on `spring-data-jpa` only as `testImplementation` + +## Controller Rules + +```kotlin +// Location: bottlenote-admin-api/src/main/kotlin/app/bottlenote/{domain}/presentation/Admin{Domain}Controller.kt +@RestController +@RequestMapping("/{plural-resources}") +class Admin{Domain}Controller( + private val service: Admin{Domain}Service +) { + // List (search) - service returns GlobalResponse with pagination + @GetMapping + fun search(@ModelAttribute request: Admin{Domain}SearchRequest): ResponseEntity<*> { + return ResponseEntity.ok(service.search(request)) + } + + // Detail + @GetMapping("/{id}") + fun getDetail(@PathVariable id: Long): ResponseEntity<*> { + return GlobalResponse.ok(service.getDetail(id)) + } + + // Create + @PostMapping + fun create(@RequestBody @Valid request: Admin{Domain}CreateRequest): ResponseEntity<*> { + val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } + return GlobalResponse.ok(service.create(request, adminId)) + } +} +``` + +## Key Rules + +| Rule | Detail | +|------|--------| +| **Package** | `app.bottlenote.{domain}.presentation` | +| **Class name** | `Admin{Domain}Controller` | +| **Mapping** | `@RequestMapping("/{plural-resources}")` (context-path `/admin/api/v1` is configured) | +| **Response (list)** | `ResponseEntity.ok(service.search(request))` — service returns `GlobalResponse` | +| **Response (other)** | `GlobalResponse.ok(service.method())` | +| **Auth** | `SecurityContextUtil.getAdminUserIdByContext()` | +| **RBAC** | Roles: `ROOT_ADMIN`, `PARTNER`, `COMMUNITY_MANAGER` | + +## Service Rules + +| Rule | Detail | +|------|--------| +| **Class name** | `Admin{Domain}Service` in mono module | +| **List query return** | `GlobalResponse.fromPage(page)` — NOT `Page` directly | +| **Detail return** | Response DTO — service handles conversion (no `from(Entity)` on DTO) | +| **CUD return** | `AdminResultResponse.of(ResultCode, targetId)` | +| **Read transactions** | `@Transactional(readOnly = true)` | +| **Write transactions** | `@Transactional` | + +## DTO Rules + +| Rule | Detail | +|------|--------| +| **DTO-Entity separation** | Response DTOs must NOT reference Entity directly (architecture rule violation) | +| **Conversion** | Use `of(...)` factory method or direct constructor in Service. `from(Entity)` is prohibited | +| **Format** | Java `record` with `@Builder` constructor for defaults | +| **Validation** | `@NotBlank`, `@NotNull`, etc. via Bean Validation | + +## Authentication + +```kotlin +val adminId = SecurityContextUtil.getAdminUserIdByContext() + .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } +``` + +## Pagination + +Admin uses **offset pagination** (vs. product's cursor pagination): +- Parameters: `page`, `size` +- Spring Data's `Pageable` interface +- Service returns `GlobalResponse.fromPage(page)` + +## HTTP Method Conventions + +| Action | Method | URL Pattern | Example | +|--------|--------|-------------|---------| +| List/Search | GET | `/{resources}` | `GET /curations` | +| Detail | GET | `/{resources}/{id}` | `GET /curations/1` | +| Create | POST | `/{resources}` | `POST /curations` | +| Full update | PUT | `/{resources}/{id}` | `PUT /curations/1` | +| Partial update | PATCH | `/{resources}/{id}/{field}` | `PATCH /curations/1/status` | +| Delete | DELETE | `/{resources}/{id}` | `DELETE /curations/1` | +| Sub-resource add | POST | `/{resources}/{id}/{sub}` | `POST /curations/1/alcohols` | +| Sub-resource remove | DELETE | `/{resources}/{id}/{sub}/{subId}` | `DELETE /curations/1/alcohols/5` | + +## Exception Pattern + +``` +RuntimeException + -> AbstractCustomException (ExceptionCode) + -> {Domain}Exception ({Domain}ExceptionCode) +``` + +- `{Domain}ExceptionCode`: implements `ExceptionCode` interface (`getMessage()`, `getHttpStatus()`) +- `{Domain}Exception`: extends `AbstractCustomException` + +## Reference Implementations + +| Feature | Controller | Service | Test | +|---------|-----------|---------|------| +| **Curation** (sub-resource mgmt) | `admin-api/.../alcohols/presentation/AdminCurationController.kt` | `mono/.../alcohols/service/AdminCurationService.java` | `admin-api/.../integration/curation/AdminCurationIntegrationTest.kt` | +| **Banner** (QueryDSL, validation) | `admin-api/.../banner/presentation/AdminBannerController.kt` | `mono/.../banner/service/AdminBannerService.java` | `admin-api/.../integration/banner/AdminBannerIntegrationTest.kt` | diff --git a/.claude/skills/implement/references/mono-patterns.md b/.claude/skills/implement/references/mono-patterns.md new file mode 100644 index 000000000..d6cfbd9d6 --- /dev/null +++ b/.claude/skills/implement/references/mono-patterns.md @@ -0,0 +1,246 @@ +# Mono Module Patterns + +## Repository 3-Tier Pattern + +### 1. Domain Repository (Required) +Pure business interface - no Spring/JPA dependency. + +```java +// Location: {domain}/domain/{Domain}Repository.java +@DomainRepository +public interface RatingRepository { + Rating save(Rating rating); + Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId); +} +``` + +### 2. JPA Repository (Required) +Implements domain repo + extends JpaRepository. + +```java +// Location: {domain}/repository/Jpa{Domain}Repository.java +@JpaRepositoryImpl +public interface JpaRatingRepository + extends JpaRepository, RatingRepository, CustomRatingRepository { +} +``` + +### 3. QueryDSL Custom Repository (Optional - complex queries only) + +```java +// Interface: {domain}/repository/Custom{Domain}Repository.java +public interface CustomRatingRepository { + PageResponse fetchRatingList(RatingListFetchCriteria criteria); +} + +// Implementation: {domain}/repository/Custom{Domain}RepositoryImpl.java +public class CustomRatingRepositoryImpl implements CustomRatingRepository { + // JPAQueryFactory injection, BooleanBuilder for dynamic conditions +} + +// Query supporter: {domain}/repository/{Domain}QuerySupporter.java +@Component +public class RatingQuerySupporter { + // Reusable query fragments +} +``` + +**Use QueryDSL only for:** dynamic multi-condition filters, multi-table joins, complex projections. +**Do NOT use for:** simple CRUD, single-condition lookups (use method query or @Query JPQL). + +## Service Pattern + +서비스는 `{Domain}Service` 하나로 작성하는 것이 기본이다. +기존 코드에 Command/Query 분리(`CommandService`/`QueryService`)가 있지만 필수 패턴이 아니며, 신규 구현 시 하나로 작성해도 됨. 기존 분리된 서비스를 굳이 합칠 필요는 없음. + +```java +@Service +@RequiredArgsConstructor +public class RatingService { + private final RatingRepository ratingRepository; + private final AlcoholFacade alcoholFacade; // 타 도메인 접근은 반드시 Facade를 통해 + + @Transactional + public RatingRegisterResponse register(Long alcoholId, Long userId, RatingPoint ratingPoint) { + Objects.requireNonNull(alcoholId, "alcoholId must not be null"); + + // 타 도메인 검증 - AlcoholFacade를 통해 요청 + if (FALSE.equals(alcoholFacade.existsByAlcoholId(alcoholId))) { + throw new RatingException(RatingExceptionCode.ALCOHOL_NOT_FOUND); + } + + // 자기 도메인 로직 + Rating rating = ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId) + .orElse(Rating.builder().alcoholId(alcoholId).userId(userId).build()); + rating.registerRatingPoint(ratingPoint); + ratingRepository.save(rating); + + // 이벤트 발행 (부수 효과) + eventPublisher.publishEvent(new RatingRegistryEvent(alcoholId, userId)); + + return new RatingRegisterResponse(rating.getId()); + } + + @Transactional(readOnly = true) + public PageResponse fetchList(RatingListFetchCriteria criteria) { + // 읽기 전용 메서드는 readOnly = true + } +} +``` + +## Aggregate Root & Facade Pattern + +### Aggregate 개념 + +도메인은 Aggregate 단위로 묶인다. 절대적인 규칙은 아니지만 개념적 경계로 활용한다. + +``` +ranking (Aggregate Root) +├── RankingService ← 외부에서 접근 가능 (Facade를 통해) +├── RankingPointService ← 내부 구현, 외부 접근 불가 +├── RankingHistoryService ← 내부 구현, 외부 접근 불가 +└── RankingFacade ← 외부에 노출하는 유일한 창구 +``` + +외부 도메인은 Aggregate Root(= Facade)를 통해서만 접근한다. Aggregate 내부의 하위 서비스에 직접 접근하면 안 된다. + +``` +[OK] UserService → RankingFacade (Aggregate Root 접근) +[OK] UserProfileService → RankingFacade (Aggregate Root 접근) +[NO] UserService → RankingPointService (하위 도메인 직접 접근) +[NO] UserProfileService → RankingHistoryService (하위 도메인 직접 접근) +``` + +### Facade의 역할 + +Facade는 Aggregate Root로서 도메인 간 경계를 보호한다. + +**왜 필요한가:** +- UserService가 RankingPointService를 직접 호출하면, Ranking 내부 구조 변경 시 UserService도 깨짐 +- RankingFacade를 통해 요청하면, Ranking이 내부 구현(서비스 분리, 테이블 구조, 캐시 전략)을 자유롭게 변경 가능 +- Facade 인터페이스는 해당 도메인이 외부에 노출하는 계약(contract) + +**원칙:** +- 같은 Aggregate 내에서는 Repository/Service를 직접 사용 +- 다른 Aggregate의 데이터가 필요하면 반드시 해당 Aggregate의 Facade를 통해 접근 +- Facade는 외부에 필요한 최소한의 인터페이스만 노출 + +``` +[OK] UserService → UserRepository (같은 Aggregate) +[OK] UserService → AlcoholFacade (타 Aggregate의 Facade) +[NO] UserService → AlcoholRepository (타 Aggregate 직접 접근) +[NO] UserService → RankingPointService (타 Aggregate 하위 서비스 직접 호출) +``` + +```java +// Interface: {domain}/facade/{Domain}Facade.java +public interface AlcoholFacade { + Boolean existsByAlcoholId(Long alcoholId); + AlcoholInfo getAlcoholInfo(Long alcoholId); +} + +// Implementation: {domain}/service/Default{Domain}Facade.java +@FacadeService +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class DefaultAlcoholFacade implements AlcoholFacade { + private final AlcoholRepository alcoholRepository; + // 자기 도메인의 Repository만 사용 +} +``` + +## DTO Patterns + +```java +// Request with validation +public record RatingRegisterRequest( + @NotNull Long alcoholId, + @NotNull Double rating +) {} + +// Pageable request with defaults +public record ReviewPageableRequest( + ReviewSortType sortType, SortOrder sortOrder, Long cursor, Long pageSize +) { + @Builder + public ReviewPageableRequest { + sortType = sortType != null ? sortType : ReviewSortType.POPULAR; + cursor = cursor != null ? cursor : 0L; + pageSize = pageSize != null ? pageSize : 10L; + } +} + +// Response with factory method +public record RatingListFetchResponse(Long totalCount, List ratings) { + public record Info(Long ratingId, Long alcoholId, Double rating) {} + public static RatingListFetchResponse create(Long total, List infos) { + return new RatingListFetchResponse(total, infos); + } +} +``` + +## Exception Pattern + +```java +// {domain}/exception/{Domain}Exception.java +public class RatingException extends AbstractCustomException { + public RatingException(RatingExceptionCode code) { + super(code); + } +} + +// {domain}/exception/{Domain}ExceptionCode.java +@Getter +public enum RatingExceptionCode implements ExceptionCode { + INVALID_RATING_POINT(HttpStatus.BAD_REQUEST, "invalid rating point"), + ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "alcohol not found"); + + private final HttpStatus httpStatus; + private final String message; + + RatingExceptionCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} +``` + +## Event Pattern + +```java +// Event record +public record RatingRegistryEvent(Long alcoholId, Long userId) {} + +// Listener +@DomainEventListener +@RequiredArgsConstructor +public class RatingEventListener { + @TransactionalEventListener + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleRatingRegistry(RatingRegistryEvent event) { + // Side effects in separate transaction + } +} +``` + +## Cursor Pagination + +```java +// In service/repository +public PageResponse fetchList(Criteria criteria) { + List items = queryFactory.selectFrom(...) + .where(cursorCondition(criteria.cursor())) + .limit(criteria.pageSize() + 1) // fetch one extra to detect hasNext + .fetch(); + + CursorPageable pageable = CursorPageable.of(items, criteria.cursor(), criteria.pageSize()); + return PageResponse.of(items.subList(0, Math.min(items.size(), criteria.pageSize())), pageable); +} + +// In controller +MetaInfos metaInfos = MetaService.createMetaInfo(); +metaInfos.add("pageable", response.cursorPageable()); +metaInfos.add("searchParameters", request); +return GlobalResponse.ok(response, metaInfos); +``` diff --git a/.claude/skills/implement/references/product-patterns.md b/.claude/skills/implement/references/product-patterns.md new file mode 100644 index 000000000..ca16c642b --- /dev/null +++ b/.claude/skills/implement/references/product-patterns.md @@ -0,0 +1,65 @@ +# Product API Controller Patterns + +## Controller Structure + +```java +// Location: bottlenote-product-api/src/main/java/app/bottlenote/{domain}/controller/{Domain}Controller.java +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/{domain}") +public class {Domain}Controller { + + private final {Domain}CommandService commandService; + private final {Domain}QueryService queryService; + + @GetMapping + public ResponseEntity list(@ModelAttribute PageableRequest request) { + Long userId = SecurityContextUtil.getUserIdByContext().orElse(-1L); + // ... + return GlobalResponse.ok(response, metaInfos); + } + + @PostMapping + public ResponseEntity create( + @RequestBody @Valid CreateRequest request + ) { + Long userId = SecurityContextUtil.getUserIdByContext() + .orElseThrow(() -> new UserException(UserExceptionCode.REQUIRED_USER_ID)); + return GlobalResponse.ok(commandService.create(request, userId)); + } +} +``` + +## Key Rules + +| Rule | Detail | +|------|--------| +| **Path** | `@RequestMapping("/api/v1/{plural-resource}")` | +| **Auth required** | `SecurityContextUtil.getUserIdByContext().orElseThrow(...)` | +| **Auth optional** | `SecurityContextUtil.getUserIdByContext().orElse(-1L)` | +| **Response** | Always wrap with `GlobalResponse.ok()` | +| **Pagination** | `CursorPageable` + `PageResponse` + `MetaInfos` | +| **Validation** | `@RequestBody @Valid` for POST/PUT/PATCH | +| **Query params** | `@ModelAttribute` for GET list endpoints | + +## Pagination Pattern + +```java +// In controller +MetaInfos metaInfos = MetaService.createMetaInfo(); +metaInfos.add("pageable", response.cursorPageable()); +metaInfos.add("searchParameters", request); +return GlobalResponse.ok(response, metaInfos); +``` + +## Endpoint URL Patterns + +| HTTP Method | Purpose | URL Pattern | Example | +|-------------|---------|-------------|---------| +| GET | List | `/api/v1/{resources}` | `GET /api/v1/reviews` | +| GET | Detail | `/api/v1/{resources}/{id}` | `GET /api/v1/reviews/1` | +| POST | Create | `/api/v1/{resources}` | `POST /api/v1/reviews` | +| PUT | Full update | `/api/v1/{resources}/{id}` | `PUT /api/v1/reviews/1` | +| PATCH | Partial | `/api/v1/{resources}/{id}` | `PATCH /api/v1/reviews/1` | +| DELETE | Delete | `/api/v1/{resources}/{id}` | `DELETE /api/v1/reviews/1` | diff --git a/.claude/skills/plan/SKILL.md b/.claude/skills/plan/SKILL.md new file mode 100644 index 000000000..4fc9b10af --- /dev/null +++ b/.claude/skills/plan/SKILL.md @@ -0,0 +1,189 @@ +--- +name: plan +description: | + Breaks work into ordered, verifiable tasks with acceptance criteria. + Trigger: "/plan", or when the user says "계획 세워줘", "태스크 분해", "plan this", "break it down". + Use after /define when requirements are clear and a plan document exists. + Adds the Tasks section to an existing plan document created by /define. +argument-hint: "[feature-name or plan file path]" +--- + +# Planning and Task Breakdown + +## Overview + +Decompose work into small, verifiable tasks with explicit acceptance criteria. Good task breakdown is the difference between an agent that completes work reliably and one that produces a tangled mess. Every task should be small enough to implement, test, and commit in a single focused session. + +This skill adds the Tasks section to an existing plan document created by `/define`. + +## When to Use + +- After `/define` has created a plan document with Overview +- When 3+ files need changes +- When work spans multiple domains or modules +- When the implementation order is not obvious + +## When NOT to Use + +- No plan document exists yet (use `/define` first) +- Single-file changes with obvious scope (just `/implement` directly) +- Bug fixes (use `/debug` directly) + +## Process + +### Step 1: Read Plan Document + +Open `plan/{feature-name}.md`. If it does not exist, prompt the user to run `/define` first. + +Read: +- Overview: what are we building and why +- Assumptions: confirmed constraints +- Success Criteria: what "done" looks like +- Impact Scope: which modules and domains are affected + +### Step 2: Dependency Analysis + +Map what depends on what. Implementation order follows the dependency graph bottom-up: + +``` +Entity / Domain model + | + +-- Repository (Domain -> JPA -> QueryDSL) + | | + | +-- Service (uses Repository + Facade) + | | | + | | +-- Controller (delegates to Service) + | | + | +-- Facade (if cross-domain access needed) + | + +-- DTO (Request / Response) + | + +-- Exception (ExceptionCode + Exception class) +``` + +Identify: +- What must be built first (foundation) +- What can be built in parallel (independent pieces) +- What depends on cross-domain Facades (coordination needed) + +### Step 3: Create Task List + +Break work into Tasks. Each Task is a commit unit. + +**Sizing guidelines:** + +| Size | Files | Scope | Action | +|------|-------|-------|--------| +| **S** | 1-3 | Single component | Good as-is | +| **M** | 4-7 | One feature slice | Good as-is | +| **L** | 8+ | Multi-component | **Must split further** | + +**Prefer vertical slices:** build one complete path through the stack rather than all layers at once. + +Bad (horizontal): +``` +Task 1: All DTOs +Task 2: All Repositories +Task 3: All Services +``` + +Good (vertical): +``` +Task 1: Rating statistics DTO + Repository + query +Task 2: Rating statistics Service + unit test +Task 3: Rating statistics Controller endpoint +Task 4: Integration test +``` + +### Step 4: Size Validation + +Check every Task: +- Is it L-sized (8+ files)? -> Split it +- Does the title contain "and"? -> Probably two Tasks +- Can acceptance criteria be described in 3 or fewer bullets? -> If not, too broad +- Does it touch two independent subsystems? -> Split by subsystem + +### Step 5: Write Tasks to Plan Document + +Append the Tasks section and empty Progress Log to the existing plan document. + +**Task entry format:** +```markdown +### Task N: [제목] +- 수용 기준: [구체적, 테스트 가능한 조건] +- 검증: [명령어 또는 확인 방법] +- 파일: [변경 예상 파일 목록] +- 크기: [S | M] +- 상태: [ ] 미완료 + +## Progress Log +(empty - filled during /implement) +``` + +Insert a checkpoint after every 2-3 Tasks: + +```markdown +### Checkpoint: Task 1-3 완료 후 +- [ ] 컴파일 통과 +- [ ] 단위 테스트 통과 +- [ ] 아키텍처 규칙 통과 +``` + +### Step 6: User Approval Gate + +Present the task list with estimated order. Get explicit approval before proceeding to `/implement`. + +``` +Tasks added to plan/{feature-name}.md + +Summary: +- [N] tasks defined (sizes: S x [n], M x [n]) +- Estimated commits: [N] +- Dependencies: [brief description] + +Approve to proceed to /implement? +``` + +## Plan Document Lifecycle + +``` +/define creates document -> Status: IN PROGRESS +/plan adds Tasks -> Tasks section populated +/implement checks off Tasks -> Progress Log updated per Task commit +All Tasks done -> Add stamp from plan/stamp-template.st + -> Move to plan/complete/ +``` + +**One feature = one document.** Do not split a feature across multiple plan files. Do not create a new document if one already exists for this feature. + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "I'll figure it out as I go" | That is how you end up with rework. 10 minutes of planning saves hours. | +| "The tasks are obvious" | Write them down. Explicit tasks surface hidden dependencies and forgotten edge cases. | +| "Planning is overhead" | Planning IS the task. Implementation without a plan is just typing. | +| "I can hold it all in my head" | Context windows are finite. Written plans survive session boundaries and compaction. | +| "This is only 2 tasks, why bother" | Even 2 tasks benefit from acceptance criteria and verification commands. | + +## Red Flags + +- Starting `/implement` without a written task list +- Tasks that say "implement the feature" without acceptance criteria +- No verification commands in the plan +- All tasks are L-sized (should be split) +- No checkpoints between tasks +- Dependency order not considered (building controllers before services) +- Task title contains "and" (probably two tasks) + +## Verification + +Before starting `/implement`, confirm: + +- [ ] Plan document has both Overview (from `/define`) and Tasks sections +- [ ] Every Task has acceptance criteria +- [ ] Every Task has a verification command +- [ ] No Task is L-sized (8+ files) +- [ ] Dependencies are ordered correctly (foundation first) +- [ ] Checkpoints exist between major groups of Tasks +- [ ] User has explicitly approved the task list diff --git a/.claude/skills/self-review/SKILL.md b/.claude/skills/self-review/SKILL.md new file mode 100644 index 000000000..a7dc61bea --- /dev/null +++ b/.claude/skills/self-review/SKILL.md @@ -0,0 +1,158 @@ +--- +name: self-review +description: | + Pre-commit quality gate with 5-axis code review. + Trigger: "/self-review", or when the user says "리뷰해줘", "review this", "코드 리뷰", "self review". + Use before every commit, after completing a Task in /implement, or when the user wants to review changes. + Evaluates code across correctness, readability, architecture, security, and performance. +argument-hint: "[files or scope]" +--- + +# Self-Review + +## Overview + +Review your own changes before committing. This is a pre-commit quality gate that catches issues before they become technical debt. Every Task completion in `/implement` should invoke this skill before committing. + +The goal is not perfection — it is continuous improvement. Approve a change when it clearly improves overall code health, even if it is not exactly how a staff engineer would have written it. + +## When to Use + +- Before every commit (mandatory in `/implement` workflow) +- After completing a Task in `/implement` +- When refactoring existing code +- When the user explicitly asks for a review + +## When NOT to Use + +- Debugging failures (use `/debug`) +- Writing new code (use `/implement`) +- Running tests (use `/verify`) +- Reviewing code that has not been written yet + +## Process + +### Step 1: Identify Scope + +Determine which files to review. + +- If called from `/implement`: review the current Task's changed files +- If standalone: use `git diff --staged` or `git diff` to identify changes +- List every changed file with a one-line summary of the change + +### Step 2: Five-Axis Review + +Evaluate every change across these dimensions. For each axis, check the project-specific items listed below. + +#### Correctness + +Does the code do what it claims to do? + +- Does it match the spec or task acceptance criteria? +- Are edge cases handled (null, empty, boundary values)? +- Are error paths handled (not just the happy path)? +- Are domain events published where expected? +- Do existing tests still pass? + +#### Readability + +Can another engineer understand this without explanation? + +- Names follow project conventions: `{Domain}Controller`, `Default{Domain}Facade`, `Jpa{Domain}Repository` +- `@DisplayName` uses Korean in format: `~할 때 ~한다` +- Comments are single-line and brief (no stating the obvious) +- DTOs use `record` for immutability +- No deep nesting (3+ levels) — use guard clauses + +#### Architecture + +Does the change fit the system design? + +- **Facade boundary**: cross-domain access goes through Facade, never direct Repository/Service +- **Repository 3-Tier**: Domain repo (pure interface) → JPA repo (implementation) → QueryDSL (complex queries only) +- **Controller thinness**: controllers delegate to services/facades, no business logic +- **Module boundary**: business logic in `bottlenote-mono`, controllers in API modules +- **Custom annotations**: `@FacadeService`, `@DomainRepository`, `@JpaRepositoryImpl` used correctly + +#### Security + +Does the change introduce vulnerabilities? + +- Authenticated endpoints use `SecurityContextUtil.getUserIdByContext()` (product) or `SecurityContextUtil.getAdminUserIdByContext()` (admin) +- Request DTOs use `@Valid` with Bean Validation annotations +- No raw SQL string concatenation (use parameterized queries or QueryDSL) +- Sensitive data not logged (passwords, tokens) +- No secrets in source code + +#### Performance + +Does the change introduce performance problems? + +- No N+1 query patterns (use fetch joins, `@BatchSize`) +- List endpoints have pagination (`CursorPageable` for product, offset for admin) +- Read-only operations use `@Transactional(readOnly = true)` +- `@Cacheable` considered for frequently accessed, rarely changed data +- No unbounded queries (always limit results) + +### Step 3: Report Findings + +Categorize every finding with a severity label: + +| Severity | Meaning | Action | +|----------|---------|--------| +| **Critical** | Blocks commit — security vulnerability, data loss, broken functionality | Must fix before commit | +| **Important** | Should fix — missing test, wrong abstraction, poor error handling | Fix or explicitly justify deferral | +| **Nit** | Optional — naming, style, minor optimization | Fix if easy, otherwise ignore | + +Format each finding as: +``` +[Severity] (Axis) file:line — description +``` + +Example: +``` +[Critical] (Architecture) RatingService.java:42 — Directly injects AlcoholRepository instead of AlcoholFacade +[Important] (Correctness) RatingController.java:28 — Missing null check for optional userId +[Nit] (Readability) RatingService.java:15 — Variable name 'r' should be descriptive +``` + +### Step 4: Resolve or Recommend + +- **Critical**: stop and fix immediately. Do not proceed to commit. +- **Important**: propose a fix, apply if straightforward, ask user if the fix changes behavior. +- **Nit**: fix silently if it is a one-line change. Otherwise, note it and move on. + +After all Critical and Important issues are resolved, proceed to commit. + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "It's just a small change, no need to review" | Small changes introduce small bugs that compound. Review everything. | +| "The tests pass, so it must be correct" | Tests check behavior, not architecture, security, or readability. All five axes matter. | +| "I'll clean it up in the next commit" | Deferred cleanup rarely happens. Fix it now or file a task. | +| "This is internal code, security doesn't matter" | Internal code gets compromised too. Validate at boundaries always. | +| "The review slows me down" | A 2-minute review prevents a 2-hour debugging session tomorrow. | + +## Red Flags + +- Skipping review for "trivial" changes +- All findings are Nit (you are not looking hard enough) +- No findings at all on a multi-file change (review more carefully) +- Ignoring Critical findings to meet a deadline +- Reviewing without understanding the feature's intent or acceptance criteria +- Facade boundaries violated (cross-domain direct access) +- Repository interface changed but InMemory implementation not updated + +## Verification + +After completing the review: + +- [ ] All Critical issues are resolved +- [ ] All Important issues are resolved or explicitly deferred with justification +- [ ] Architecture rules respected (Facade boundaries, Repository 3-Tier) +- [ ] Security checks in place for authenticated endpoints +- [ ] No new N+1 patterns introduced +- [ ] Code compiles: `./gradlew compileJava compileTestJava` +- [ ] Unit tests pass: `./gradlew unit_test` +- [ ] Architecture rules pass: `./gradlew check_rule_test` diff --git a/.claude/skills/test/SKILL.md b/.claude/skills/test/SKILL.md new file mode 100644 index 000000000..1b4011ffd --- /dev/null +++ b/.claude/skills/test/SKILL.md @@ -0,0 +1,221 @@ +--- +name: test +description: | + Test implementation guide for bottle-note-api-server (product-api & admin-api). + Trigger: "/test", or when the user says "테스트 작성", "테스트 구현", "테스트 추가", "write tests", "implement tests". + Guides through unit test (Fake/Stub), integration test, and RestDocs test creation. + Supports both product-api (Java) and admin-api (Kotlin) modules. +argument-hint: "[domain] [product|admin] [unit|integration|restdocs|all]" +--- + +# Test Implementation + +References: +- `references/test-infra.md` — shared test utilities, TestContainers, existing Fake/InMemory list +- `references/test-patterns.md` — unit, integration, RestDocs code patterns + +## Overview + +Write tests that prove code works. This skill guides you through creating unit tests (Fake/Stub pattern), integration tests (TestContainers), and optionally RestDocs tests. Tests are proof — "seems right" is not done. + +## When to Use + +- After `/implement` completes, to add integration tests +- When adding test coverage to existing untested code +- When the user explicitly requests test creation +- During `/implement` Task cycle, for writing unit tests alongside implementation + +## When NOT to Use + +- Running existing tests (use `/verify`) +- Debugging test failures (use `/debug`) +- Requirements unclear (use `/define` first) + +## Relationship with `/implement` + +- **Unit tests**: ideally written together with implementation during a Task in `/implement` +- **Integration tests**: written after all Tasks are implemented, via separate `/test` invocation +- **RestDocs tests**: only when user explicitly requests API documentation +- Slice-level compile checks in `/implement` run existing tests — this skill WRITES new tests + +## Test Types and Timing + +| Test Type | Tag | When to Write | When to Run | Command | +|-----------|-----|---------------|-------------|---------| +| **Unit test** | `@Tag("unit")` | With `/implement` Task | Task commit | `./gradlew unit_test` | +| **Architecture rules** | `@Tag("rule")` | Already exists (ArchUnit) | Task commit | `./gradlew check_rule_test` | +| **Integration test** | `@Tag("integration")` | After feature complete | `/verify full` | `./gradlew integration_test` | +| **Admin integration** | `@Tag("admin_integration")` | After feature complete | `/verify full` | `./gradlew admin_integration_test` | +| **RestDocs** | (none) | User request only | Documentation build | `./gradlew restDocsTest` | + +## Test Pattern Selection + +``` +New test needed: +├── Service logic? +│ ├── Fake/InMemory exists for this domain? +│ │ ├── Yes -> Use Fake pattern (reuse existing InMemory) +│ │ └── No -> Create InMemory implementation first, then Fake pattern +│ └── Mockito is LAST RESORT (ask user before using) +├── API endpoint? +│ ├── product -> IntegrationTestSupport + mockMvcTester (Java) +│ └── admin -> IntegrationTestSupport + mockMvcTester (Kotlin) +└── API documentation? + └── RestDocs (only when user explicitly requests) +``` + +## Argument Parsing + +Parse `$ARGUMENTS` to determine: +- **domain**: target domain (e.g., `alcohols`, `rating`, `review`) +- **module**: `product` (default) or `admin` +- **scope**: `unit`, `integration`, `restdocs`, or `all` (default: `unit` + `integration`) + +## Process + +### Phase 0: Explore + +Before writing tests, understand the implementation: + +1. Read the service class to identify testable methods and branches +2. Check existing test infrastructure: + - Fake/InMemory repositories for the domain (see `references/test-infra.md`) + - TestFactory for the domain + - ObjectFixture for the domain +3. Report findings: what exists, what needs to be created + +### Phase 1: Scenario Definition + +Define test scenario lists based on service methods and API endpoints. +Write each scenario in `@DisplayName` format (Korean: `~할 때 ~한다`) and get user approval before implementation. + +**Unit test scenarios** (per service method): +- Success: expected behavior with valid input +- Failure: exception conditions (not found, unauthorized, duplicate, etc.) +- Edge cases: null, empty, boundary values + +**Integration test scenarios** (per API endpoint): +- Authenticated request + successful response +- Authentication failure (401) +- Business validation failure (400, 404, 409, etc.) + +Example: +``` +Unit: RatingService +- 유효한 요청이면 평점을 등록할 수 있다 +- 존재하지 않는 주류에 평점을 등록하면 예외가 발생한다 +- 이미 평점이 있으면 기존 평점을 갱신한다 +- 평점 등록 시 이벤트가 발행된다 + +Integration: POST /api/v1/ratings +- 인증된 사용자가 평점을 등록할 수 있다 +- 인증 없이 요청하면 401을 반환한다 +- 존재하지 않는 주류 ID로 요청하면 404를 반환한다 +``` + +Present the scenario list to the user and proceed to Phase 2 after approval. + +### Phase 2: Test Infrastructure (create if missing) + +**For Unit Tests:** +- `InMemory{Domain}Repository` in fixture package +- `{Domain}ObjectFixture` for pre-configured domain objects + +**For Integration Tests (product):** +- `{Domain}TestFactory` in `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/` + +**For Integration Tests (admin):** +- `{Domain}Helper` (Kotlin object) in `bottlenote-admin-api/src/test/kotlin/app/helper/{domain}/` + +### Phase 3: Test Implementation + +Read `references/test-patterns.md` for code examples before writing tests. + +**Unit Test** (`@Tag("unit")`): + +| Item | Product (Java) | Admin | +|------|---------------|-------| +| Location | `product-api/.../app/bottlenote/{domain}/service/` | N/A (business logic in mono) | +| Pattern | Fake/Stub (Mock is last resort, ask user first) | - | +| Naming | `Fake{Domain}ServiceTest` | - | + +Structure: +- `@BeforeEach`: wire SUT with InMemory repos + Fake facades +- `@Nested` + `@DisplayName`: group by method/scenario +- Given-When-Then in each test + +**Integration Test** (`@Tag("integration")` or `@Tag("admin_integration")`): + +| Item | Product (Java) | Admin (Kotlin) | +|------|---------------|----------------| +| Location | `product-api/.../app/bottlenote/{domain}/integration/` | `admin-api/.../app/integration/{domain}/` | +| Base class | `IntegrationTestSupport` | `IntegrationTestSupport` | +| Tag | `@Tag("integration")` | `@Tag("admin_integration")` | +| API client | `mockMvcTester` | `mockMvcTester` | +| Auth | `getToken()` / `getToken(user)` | `getAccessToken(admin)` | +| Data setup | `{Domain}TestFactory` (`@Autowired`) | `{Domain}TestFactory` (`@Autowired`) | + +**RestDocs Test** (optional, user request only): + +| Item | Product (Java) | Admin (Kotlin) | +|------|---------------|----------------| +| Location | `product-api/.../app/docs/{domain}/` | `admin-api/.../app/docs/{domain}/` | +| Base class | `AbstractRestDocs` | `@WebMvcTest(excludeAutoConfiguration = [SecurityAutoConfiguration::class])` | +| Naming | `Rest{Domain}ControllerDocsTest` | `Admin{Domain}ControllerDocsTest` | +| Mocking | `@MockBean` services (acceptable here) | `@MockitoBean` services | + +### Phase 4: Verify + +After test implementation, run verification: + +| Scope | Command | +|-------|---------| +| Unit tests only | `/verify standard` (compile + unit + build) | +| With integration | `/verify full` (includes integration tests) | + +## Test Naming Convention + +- Class: `Fake{Feature}ServiceTest`, `{Feature}IntegrationTest`, `Rest{Domain}ControllerDocsTest` +- Method: `{action}_{scenario}_{expectedResult}` or Korean `@DisplayName` +- DisplayName: always in Korean, format `~할 때 ~한다`, `~하면 ~할 수 있다` + +## Important Rules + +- **Mock is last resort**: always prefer Fake/InMemory. Ask user before using Mockito. +- **RestDocs is optional**: only implement when user explicitly requests. +- **One test, one scenario**: each `@Test` verifies a single behavior. +- **Repository interface changes**: if you added methods to a domain repository, update the corresponding `InMemory{Domain}Repository` too. + +## Common Rationalizations + +| Rationalization | Reality | +|-----------------|---------| +| "I'll write tests after the code works" | Tests written after the fact test implementation, not behavior. Write them alongside. | +| "Mock is faster than building a Fake" | Mock couples tests to implementation details. Fakes survive refactoring. | +| "This is too simple to test" | Simple code gets complicated. The test documents expected behavior. | +| "Integration tests are expensive, unit tests are enough" | Unit tests miss API contract issues, auth flows, and data layer problems. Both are needed. | +| "RestDocs is always needed" | RestDocs is optional. Only create when the user explicitly requests documentation. | + +## Red Flags + +- Using Mockito without asking the user first +- Test class missing `@Tag` annotation +- `@DisplayName` not in Korean or not describing behavior +- Testing framework behavior instead of application logic +- No `@Nested` grouping on a test class with 5+ test methods +- Integration test not extending `IntegrationTestSupport` +- Tests that pass on the first run (may not be testing what you think) +- Skipping tests to make the suite pass + +## Verification + +After completing test implementation: + +- [ ] Every new behavior has a corresponding test +- [ ] All test scenarios from Phase 1 are implemented +- [ ] Test names describe the behavior being verified (Korean `@DisplayName`) +- [ ] No tests were skipped or disabled +- [ ] Unit tests use Fake/InMemory pattern (not Mockito, unless approved) +- [ ] Integration tests extend `IntegrationTestSupport` +- [ ] All tests pass: `./gradlew unit_test` and/or `./gradlew integration_test` +- [ ] InMemory repositories updated if domain repo interface changed diff --git a/.claude/skills/test/references/test-infra.md b/.claude/skills/test/references/test-infra.md new file mode 100644 index 000000000..976146506 --- /dev/null +++ b/.claude/skills/test/references/test-infra.md @@ -0,0 +1,149 @@ +# Test Infrastructure + +## Test Classification + +| Tag | Type | Base Class | Location | +|-----|------|------------|----------| +| `@Tag("unit")` | Unit test | None (plain JUnit) | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/service/` | +| `@Tag("integration")` | Integration test | `IntegrationTestSupport` | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/integration/` | +| `@Tag("admin_integration")` | Admin integration | `IntegrationTestSupport` | `bottlenote-admin-api/src/test/kotlin/app/integration/{domain}/` | +| (none) | RestDocs test | `AbstractRestDocs` | `bottlenote-product-api/src/test/java/app/docs/{domain}/` | + +## Shared Test Utilities + +### IntegrationTestSupport + +Location: `bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java` + +**Fields:** +- `ObjectMapper mapper` - JSON serialization +- `MockMvc mockMvc` - legacy Spring MVC test API +- `MockMvcTester mockMvcTester` - modern Spring 6+ fluent API (prefer this) +- `TestAuthenticationSupport authSupport` - token generation +- `DataInitializer dataInitializer` - DB cleanup + +**Helper methods:** +- `getToken()` - default user token (3 overloads: no-arg, User, userId) +- `getTokenUserId()` - get userId from default token +- `extractData(MvcTestResult, Class)` - parse GlobalResponse.data into target type +- `extractData(MvcResult, Class)` - legacy MockMvc version +- `parseResponse(MvcTestResult)` - parse raw response to GlobalResponse +- `parseResponse(MvcResult)` - legacy version + +**Auto cleanup:** `@AfterEach` calls `dataInitializer.deleteAll()` which TRUNCATEs all tables except system tables (databasechangelog, flyway, schema_version). + +### TestContainersConfig + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` + +Containers (all with `@ServiceConnection` for auto-wiring): +- **MySQL 8.0.32** - `withReuse(true)`, DB name from `System.getProperty("testcontainers.db.name", "bottlenote")` +- **Redis 7.0.12** - `withReuse(true)` +- **MinIO** - S3-compatible storage with `AmazonS3` client bean + +Fake beans (`@Primary`, replaces real implementations in test context): +- `FakeWebhookRestTemplate` - captures HTTP calls instead of sending +- `FakeProfanityClient` - returns clean text without calling external API + +### TestAuthenticationSupport + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java` + +- `getFirstUser()` - first user from DB +- `getAccessToken()` - default access token +- `getRandomAccessToken()` - random user token +- `createToken(OauthRequest)` / `createToken(User)` - custom token generation +- `getDefaultUserId()` / `getUserId(String email)` - user ID lookup + +### DataInitializer + +Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java` + +- `deleteAll()` - TRUNCATE all user tables (dynamic table discovery via `SHOW TABLES`) +- `refreshCache()` - refresh table list for dynamically created tables +- `@Transactional(REQUIRES_NEW)` for isolation +- Filters: `databasechangelog*`, `flyway_*`, `schema_version`, `BATCH_*`, `QRTZ_*` + +## Test Data Patterns + +### TestFactory (Integration tests) + +Location: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/{Domain}TestFactory.java` + +TestFactory uses `EntityManager` + `@Transactional` (not JPA repositories). Method naming: `persist{Entity}()`. + +```java +@Component +public class RatingTestFactory { + @PersistenceContext + private EntityManager em; + + @Autowired + private AlcoholTestFactory alcoholTestFactory; + + private final AtomicInteger counter = new AtomicInteger(0); + + @Transactional + public Rating persistRating(Long alcoholId, Long userId) { + Rating rating = Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + em.persist(rating); + em.flush(); + return rating; + } +} +``` + +Key patterns: +- `AtomicInteger counter` for unique suffixes (names, emails) +- `persistAndFlush()` variants for immediate ID access +- Compose other factories: `AlcoholTestFactory` uses `RegionTestFactory`, `DistilleryTestFactory` + +### ObjectFixture (Unit tests) + +Location: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/{Domain}ObjectFixture.java` + +Static factory methods returning pre-configured domain objects. No DB, no Spring. + +```java +public class RatingObjectFixture { + public static Rating createRating(Long alcoholId, Long userId) { + return Rating.builder() + .alcoholId(alcoholId) + .userId(userId) + .ratingPoint(RatingPoint.FOUR) + .build(); + } + + public static RatingRegisterRequest createRegisterRequest() { + return new RatingRegisterRequest(1L, 4.5); + } +} +``` + +Use ObjectFixture for unit tests, TestFactory for integration tests. + +## Existing Fake/InMemory Implementations + +Before creating a new Fake, check if one already exists. + +### InMemory Repositories +- `InMemoryRatingRepository`, `InMemoryReviewRepository`, `InMemoryLikesRepository` +- `InMemoryUserRepository`, `InMemoryUserQueryRepository` +- `InMemoryAlcoholQueryRepository`, `InMemoryFollowRepository` +- `InMemoryPicksRepository` (also `FakePicksRepository` in `picks/fake/`) +- Plus others for block, support, banner, etc. + +### Fake Services/Facades +- `FakeAlcoholFacade` - in-memory with `add()`, `remove()`, `clear()` +- `FakeUserFacade` - similar pattern +- `FakeHistoryEventPublisher` - captures published history events +- `FakeApplicationEventPublisher` - captures all Spring events (`getPublishedEvents()`, `getPublishedEventsOfType()`, `hasPublishedEventOfType()`, `clear()`) + +### Fake External Services +- `FakeWebhookRestTemplate` - captures HTTP calls (`getCallCount()`, `getLastRequestBody()`) +- `FakeProfanityClient` - returns input text as-is +- Fake JWT/BCrypt implementations for security testing diff --git a/.claude/skills/test/references/test-patterns.md b/.claude/skills/test/references/test-patterns.md new file mode 100644 index 000000000..539d1b67e --- /dev/null +++ b/.claude/skills/test/references/test-patterns.md @@ -0,0 +1,230 @@ +# Test Patterns + +## 1. Unit Test - Fake/Stub Pattern (Preferred) + +Use InMemory implementations instead of mocks. Closer to real behavior and resilient to refactoring. + +### Fake Repository + +```java +// Location: {domain}/fixture/InMemory{Domain}Repository.java +public class InMemoryRatingRepository implements RatingRepository { + private final Map database = new HashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1); + + @Override + public Rating save(Rating rating) { + if (rating.getId() == null) { + ReflectionTestUtils.setField(rating, "id", idGenerator.getAndIncrement()); + } + database.put(rating.getId(), rating); + return rating; + } + + @Override + public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { + return database.values().stream() + .filter(r -> r.getAlcoholId().equals(alcoholId) && r.getUserId().equals(userId)) + .findFirst(); + } +} +``` + +### Fake Test + +```java +// Location: {domain}/service/Fake{Domain}ServiceTest.java +@Tag("unit") +@DisplayName("{Domain} service unit test") +class FakeRatingCommandServiceTest { + + private RatingCommandService sut; // system under test + private InMemoryRatingRepository ratingRepository; + private FakeApplicationEventPublisher eventPublisher; + + @BeforeEach + void setUp() { + ratingRepository = new InMemoryRatingRepository(); + eventPublisher = new FakeApplicationEventPublisher(); + sut = new RatingCommandService(ratingRepository, eventPublisher, /* other fakes */); + } + + @Nested + @DisplayName("when registering a rating") + class RegisterRating { + + @Test + @DisplayName("valid request registers the rating") + void register_whenValidRequest_savesRating() { + // given + Long alcoholId = 1L; + Long userId = 1L; + RatingPoint point = RatingPoint.FIVE; + + // when + RatingRegisterResponse response = sut.register(alcoholId, userId, point); + + // then + assertThat(response).isNotNull(); + assertThat(ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId)).isPresent(); + } + + @Test + @DisplayName("event is published") + void register_publishesEvent() { + sut.register(1L, 1L, RatingPoint.FIVE); + + assertThat(eventPublisher.getPublishedEvents()) + .hasSize(1) + .first() + .isInstanceOf(RatingRegistryEvent.class); + } + } +} +``` + +## 2. Unit Test - Mockito (Last Resort Only) + +Mockito couples tests to implementation details and breaks on refactoring. +**Always ask the user before choosing Mockito over Fake/Stub.** + +```java +@Tag("unit") +@ExtendWith(MockitoExtension.class) +class RatingCommandServiceTest { + + @InjectMocks + private RatingCommandService sut; + + @Mock + private RatingRepository ratingRepository; + + @Mock + private AlcoholFacade alcoholFacade; + + @Test + @DisplayName("non-existent alcohol throws exception") + void register_whenAlcoholNotFound_throwsException() { + // given + given(alcoholFacade.existsByAlcoholId(anyLong())).willReturn(false); + + // when & then + assertThatThrownBy(() -> sut.register(1L, 1L, RatingPoint.FIVE)) + .isInstanceOf(RatingException.class); + } +} +``` + +## 3. Integration Test + +Full Spring context with TestContainers (real DB). +Read `test-infra.md` for IntegrationTestSupport details. + +```java +// Location: {domain}/integration/{Domain}IntegrationTest.java +@Tag("integration") +@DisplayName("{Domain} integration test") +class RatingIntegrationTest extends IntegrationTestSupport { + + @Autowired + private RatingTestFactory ratingTestFactory; + + @Test + @DisplayName("register a rating") + void registerRating() { + // given - TestFactory for data setup + User user = userTestFactory.persistUser(); + Alcohol alcohol = alcoholTestFactory.persistAlcohol(); + String token = getToken(user); + + RatingRegisterRequest request = new RatingRegisterRequest(alcohol.getId(), 4.5); + + // when - MockMvcTester (modern API) + MvcTestResult result = mockMvcTester.post() + .uri("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + token) + .content(mapper.writeValueAsString(request)) + .exchange(); + + // then - helper methods + assertThat(result).hasStatusOk(); + GlobalResponse response = parseResponse(result); + assertThat(response.getSuccess()).isTrue(); + } +} +``` + +### Async Event Verification (Awaitility) + +```java +@Test +void register_triggersHistoryEvent() { + // ... perform action ... + + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List histories = historyRepository.findByUserId(userId); + assertThat(histories).hasSize(1); + }); +} +``` + +## 4. RestDocs Test + +API documentation with Spring REST Docs. Only implement when user explicitly requests. + +```java +// Location: app/docs/{domain}/Rest{Domain}ControllerDocsTest.java +class RestRatingControllerDocsTest extends AbstractRestDocs { + + @MockBean + private RatingCommandService commandService; + + @MockBean + private RatingQueryService queryService; + + @Override + protected Object initController() { + return new RatingController(commandService, queryService); + } + + @Test + @DisplayName("rating registration API docs") + void registerRating() throws Exception { + // given + given(commandService.register(anyLong(), anyLong(), any())) + .willReturn(new RatingRegisterResponse(1L)); + + mockSecurityContext(1L); // static mock for SecurityContextUtil + + // when & then + mockMvc.perform(post("/api/v1/ratings") + .contentType(MediaType.APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andDo(document("rating-register", + requestFields( + fieldWithPath("alcoholId").type(NUMBER).description("alcohol ID"), + fieldWithPath("rating").type(NUMBER).description("rating value") + ), + responseFields( + fieldWithPath("success").type(BOOLEAN).description("success"), + fieldWithPath("code").type(NUMBER).description("status code"), + fieldWithPath("data").type(OBJECT).description("response data"), + fieldWithPath("data.ratingId").type(NUMBER).description("rating ID"), + fieldWithPath("errors").type(ARRAY).description("error list"), + fieldWithPath("meta").type(OBJECT).description("meta info"), + fieldWithPath("meta.serverVersion").type(STRING).description("server version"), + fieldWithPath("meta.serverEncoding").type(STRING).description("encoding"), + fieldWithPath("meta.serverResponseTime").type(STRING).description("response time"), + fieldWithPath("meta.serverPathVersion").type(STRING).description("API version") + ) + )); + } +} +``` + +Note: RestDocs tests use `AbstractRestDocs` which sets up standalone MockMvc with `MockMvcBuilders.standaloneSetup()`, pretty-print configuration, and `GlobalExceptionHandler`. These tests use `@MockBean` for services - this is the one place where mocking is acceptable because we're testing documentation, not business logic. diff --git a/CLAUDE.md b/CLAUDE.md index 6ea5dc7ba..fda653c29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,111 +99,28 @@ git submodule update --init --recursive - `storage/mysql/init/*.sql`: TestContainers용 DB 초기화 스크립트 - 통합 테스트 실행 전 서브모듈 초기화 필수 -## Admin API 구현 규칙 - -### Admin API 구현 체크리스트 - -새로운 Admin API를 구현할 때 다음 체크리스트를 따르세요: - -#### 1. 요구사항 분석 및 설계 -- [ ] product-api에 동일/유사 기능이 있는지 확인 -- [ ] mono 모듈의 기존 서비스/도메인 로직 재사용 가능 여부 확인 -- [ ] 신규 서비스 메서드가 필요한 경우 mono 모듈에 추가 계획 -- [ ] API 엔드포인트 설계 (HTTP Method, URL 패턴) - -#### 2. mono 모듈 수정 (필요 시) -- [ ] 서비스 클래스에 admin 전용 메서드 추가 (예: `xxxForAdmin`) -- [ ] 인증 방식 분리: admin은 `adminId`를 파라미터로 받도록 설계 -- [ ] 공통 로직 추출 및 리팩토링 (`private` 메서드 분리) -- [ ] 기존 테스트 영향 확인 및 수정 - -#### 3. admin-api 컨트롤러 구현 -- [ ] 패키지: `app.bottlenote.{domain}.presentation` -- [ ] 클래스명: `Admin{도메인명}Controller` -- [ ] `@RestController`, `@RequestMapping("/{리소스}")` 설정 -- [ ] 인증이 필요한 API: `SecurityContextUtil.getAdminUserIdByContext()` 호출 -- [ ] 응답: `GlobalResponse.ok(response)` 래핑 - -#### 4. 테스트 작성 -- [ ] 통합 테스트: `app/integration/{domain}/Admin{도메인명}IntegrationTest.kt` - - `IntegrationTestSupport` 상속 - - `@Tag("admin_integration")` 태그 - - 인증 성공/실패 케이스 - - 주요 비즈니스 시나리오 -- [ ] RestDocs 테스트 (API 문서화 필요 시): `app/docs/{domain}/Admin{도메인명}ControllerDocsTest.kt` - -#### 5. 검증 및 완료 -- [ ] 컴파일 확인: `./gradlew :bottlenote-admin-api:compileKotlin` -- [ ] 테스트 실행: `./gradlew :bottlenote-admin-api:admin_integration_test` -- [ ] API 문서 생성: `./gradlew :bottlenote-admin-api:asciidoctor` (RestDocs 테스트 작성 시) - -### 컨트롤러 작성 규칙 - -1. **패키지 위치**: `app.bottlenote.{domain}.presentation` -2. **클래스명**: `Admin{도메인명}Controller` -3. **매핑**: `@RequestMapping("/{복수형 리소스}")` (예: `/helps`, `/alcohols`) -4. **응답 타입**: `ResponseEntity<*>` 또는 `ResponseEntity` -5. **응답 래핑**: `GlobalResponse.ok(response)` 사용 - -### API 엔드포인트 설계 - -| HTTP Method | 용도 | URL 패턴 | 예시 | -|-------------|------|----------|------| -| GET | 목록 조회 | `/{resources}` | `GET /helps` | -| GET | 단건 조회 | `/{resources}/{id}` | `GET /helps/1` | -| POST | 생성/액션 | `/{resources}` 또는 `/{resources}/{id}/{action}` | `POST /helps/1/answer` | -| PUT | 전체 수정 | `/{resources}/{id}` | `PUT /helps/1` | -| PATCH | 부분 수정 | `/{resources}/{id}` | `PATCH /helps/1` | -| DELETE | 삭제 | `/{resources}/{id}` | `DELETE /helps/1` | - -### 요청/응답 처리 - -1. **목록 조회 요청**: `@ModelAttribute` + Request DTO -2. **단건 조회**: `@PathVariable` -3. **생성/수정 요청**: `@RequestBody @Valid` + Request DTO -4. **페이징 응답**: `PageResponse.of(content, cursorPageable)` - -### 인증이 필요한 API - -```kotlin -val adminId = SecurityContextUtil.getAdminUserIdByContext() - .orElseThrow { UserException(UserExceptionCode.REQUIRED_USER_ID) } -``` - -### 서비스 의존 - -- 컨트롤러는 mono 모듈의 서비스를 직접 주입받아 사용 -- 비즈니스 로직은 mono 모듈에 구현 -- admin-api는 프레젠테이션 계층만 담당 +## Skills (Development Workflow) -### 테스트 작성 규칙 +Use these skills to follow the structured development lifecycle: -**통합 테스트** (`app/integration/{domain}/`): -- `IntegrationTestSupport` 상속 -- `@Tag("admin_integration")` 태그 -- `@Nested` 클래스로 API별 그룹화 -- `mockMvcTester`로 API 호출 및 검증 +| Command | Purpose | When to Use | +|---------|---------|-------------| +| `/define` | Requirements clarification | Starting a new feature, vague requirements | +| `/plan` | Task breakdown | After /define, multi-file changes | +| `/implement` | Incremental implementation | Building features (product + admin) | +| `/test` | Test creation | Unit, integration, RestDocs tests | +| `/verify` | Local CI verification | Compile, unit test, integration test | +| `/debug` | Systematic debugging | Build/test failures, unexpected errors | +| `/self-review` | Pre-commit quality gate | Before every commit | -**RestDocs 테스트** (`app/docs/{domain}/`): -- `@WebMvcTest(controllers = [...], excludeAutoConfiguration = [SecurityAutoConfiguration::class])` -- `@MockitoBean`으로 서비스 목킹 -- `document()` 메서드로 API 문서 스니펫 생성 +**Lifecycle:** `/define` -> `/plan` -> `/implement` (with `/self-review` per Task) -> `/test` -> `/verify full` -### 헬퍼 클래스 - -- **위치**: `app/helper/{domain}/` -- **형태**: `object` 싱글톤 -- **용도**: 테스트 데이터 생성 -- **네이밍**: `{도메인명}Helper` - -```kotlin -object AlcoholsHelper { - fun createAdminAlcoholItem( - id: Long = 1L, - korName: String = "테스트 위스키" - ): AdminAlcoholItem = ... -} -``` +**Detailed patterns** for product-api and admin-api implementation are in skill reference files: +- `.claude/skills/implement/references/mono-patterns.md` — Repository 3-Tier, Facade, DTO, Event patterns +- `.claude/skills/implement/references/product-patterns.md` — Product controller conventions +- `.claude/skills/implement/references/admin-patterns.md` — Admin controller conventions (Kotlin) +- `.claude/skills/test/references/test-infra.md` — TestContainers, Fake/InMemory list +- `.claude/skills/test/references/test-patterns.md` — Unit, integration, RestDocs code patterns ## 코드 작성 규칙 @@ -283,32 +200,14 @@ object AlcoholsHelper { - **Fake/Stub 패턴 선호**: Mock 대신 InMemory 구현체 사용 - **네이밍**: `InMemory{도메인명}Repository`, `Fake{서비스명}` - **위치**: `{도메인}.fixture` 패키지 -- **이벤트 테스트**: `FakeApplicationEventPublisher`로 발행된 이벤트 검증 - -```java -// 예시: InMemory 레포지토리 -public class InMemoryReviewRepository implements ReviewRepository { - private final Map database = new HashMap<>(); - // 도메인 레포지토리 인터페이스 구현 -} -``` ### 통합 테스트 패턴 - **베이스 클래스**: `IntegrationTestSupport` 상속 -- **API 테스트**: `MockMvcTester` 사용, `extractData()` 메서드로 응답 추출 -- **비동기 대기**: `Awaitility`로 이벤트 처리 대기 -- **테스트 데이터 생성**: `{도메인명}TestFactory` 사용 (`@Autowired`) - -```java -// 예시: 비동기 이벤트 대기 -Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted(() -> { - List logs = repository.findByUserId(userId); - assertEquals(2, logs.size()); - }); -``` +- **API 테스트**: `MockMvcTester` 사용 +- **테스트 데이터 생성**: `{도메인명}TestFactory` 사용 + +> Detailed code examples: see `/test` skill references (`test-infra.md`, `test-patterns.md`) ### 이벤트 기반 아키텍처 diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md index dfed0e37f..37c93e226 100644 --- a/plan/claude-ai-harness-improvement.md +++ b/plan/claude-ai-harness-improvement.md @@ -1,395 +1,577 @@ -# Claude AI 하드 하네스 구조 개선안 +# Claude AI Harness 개선 설계서 + +> 작성일: 2026-04-08 +> 참고: [agent-skills](https://github.com/addyosmani/agent-skills) (Addy Osmani) +> 상태: 설계 단계 -> CLAUDE.md 중심 구조에서 Skills + Hooks 기반 분산 구조로 전환 --- -## 1. 현재 구조 분석 +## 1. 현재 구조 진단 -### 현재 파일 구성 +### 현재 스킬 목록 -``` -.claude/ -├── settings.json # 훅: SessionStart (Docker 세팅)만 존재 -├── hooks/ -│ └── session-start.sh # 원격 환경 Docker 설치 전용 -├── docs/ -│ └── ADMIN-API-GUIDE.md # 210줄, 스킬/훅 미연결 (사실상 사문서) -└── skills/ - └── deploy-batch/ # 배포 스킬 1개만 존재 - ├── SKILL.md - └── scripts/*.sh +| 스킬 | 대상 | 역할 | +|------|------|------| +| `/implement-product-api` | product-api (Java) | 기능 구현 가이드 | +| `/implement-test` | product + admin | 테스트 작성 가이드 | +| `/verify` | 전체 | 로컬 CI 검증 (L1/L2/L3) | + +### 현재 워크플로우 -CLAUDE.md # ~210줄, 모든 규칙이 여기에 집중 +```mermaid +flowchart LR + A["/implement-product-api"] --> B["/implement-test"] + B --> C["/verify"] + C --> D["수동 커밋/PR"] ``` ### 문제점 -| 문제 | 영향 | +1. **모듈별 분산**: product-api와 admin-api 구현 스킬이 분리되어야 할 이유가 없음 + - 둘 다 결국 "mono에 비즈니스 로직 → API 모듈에 컨트롤러" 패턴 + - `/implement-test`는 이미 `[product|admin]` 인자로 통합되어 있음 +2. **라이프사이클 누락**: 요구사항 정의, 계획, 디버깅, 리뷰 단계가 없음 +3. **방어 장치 부족**: 핑계 차단(Rationalizations), 경고 신호(Red Flags), 종료 검증(Verification)이 없음 +4. **스킬 간 연계 불명확**: 어떤 순서로 어떤 스킬을 써야 하는지 가이드가 없음 + +--- + +## 2. 개선 방향: 에이전틱 스킬 체계 + +### 핵심 아이디어 + +**"프로젝트 특화 지식"은 references에, "워크플로우"는 스킬에 분리한다.** + +현재 `/implement-product-api`는 두 가지가 섞여 있음: +- **워크플로우**: Phase 0 → Phase 1 → Phase 2 → Phase 3 (이건 범용적) +- **프로젝트 패턴**: Repository 3-Tier, Facade, DTO 패턴 (이건 프로젝트 고유) + +개선 후에는: +- 스킬 = **"무엇을 어떤 순서로 할 것인가"** (워크플로우, 게이트, 검증) +- references = **"이 프로젝트에서는 어떻게 하는가"** (코드 패턴, 컨벤션) + +### 통합 원칙 + +| 원칙 | 설명 | |------|------| -| CLAUDE.md에 개요 + 구현 규칙 + 테스트 규칙 + 코드 스타일 전부 포함 | 컨텍스트 윈도우 낭비, 모든 대화에 전부 로딩됨 | -| ADMIN-API-GUIDE.md가 스킬로 연결되지 않음 | 수동으로 읽어야만 참조 가능 | -| 포매팅(spotless) 자동화 없음 | Claude가 수정한 코드가 포맷 위반 상태로 남음 | -| 커밋 메시지 검증 없음 | 규칙(타입: 제목) 위반 가능 | -| 구현 워크플로우가 코드화되지 않음 | 매번 대화로 가이드를 재설명해야 함 | +| **모듈 통합** | product/admin을 하나의 `/implement` 스킬로 통합, 인자로 모듈 선택 | +| **라이프사이클 완성** | DEFINE → PLAN → BUILD → TEST → VERIFY → REVIEW | +| **방어 장치 내장** | 모든 스킬에 Rationalizations + Red Flags + Verification | +| **게이트 워크플로우** | 각 단계 사이에 사용자 승인 게이트 | -### 참고: 프론트엔드 프로젝트 (이미 스킬 구조 적용) +--- -``` -bottle-note-frontend/.claude/skills/Code/ -├── SKILL.md # 메인 스킬 -└── Workflows/ - ├── api.md # /api - API 함수 + Query 훅 생성 - ├── component.md # /component - ├── fix.md # /fix - ├── hook.md # /hook - ├── page.md # /page - ├── refactor.md # /refactor - ├── store.md # /store - └── test.md # /test +## 3. 새로운 스킬 체계 + +### 전체 라이프사이클 + +```mermaid +flowchart TD + subgraph DEFINE["DEFINE 단계"] + spec["/define
요구사항 명확화"] + end + + subgraph PLAN["PLAN 단계"] + plan["/plan
태스크 분해"] + end + + subgraph BUILD["BUILD 단계"] + build["/implement
점진적 구현"] + test["/test
테스트 작성"] + end + + subgraph VERIFY_STAGE["VERIFY 단계"] + verify["/verify
로컬 CI"] + debug["/debug
디버깅"] + end + + subgraph REVIEW["REVIEW 단계"] + review["/self-review
코드 리뷰"] + end + + spec -->|"사용자 승인"| plan + plan -->|"사용자 승인"| build + build <-->|"Slice 단위 반복"| test + build --> verify + test --> verify + verify -->|"실패"| debug + debug -->|"수정 후"| verify + verify -->|"L2 통과"| review + review -->|"승인"| ship["커밋 / PR"] + review -->|"수정 필요"| build ``` +### 스킬 매핑 (현재 → 개선) + +| 현재 | 개선 후 | 변화 | +|------|---------|------| +| `/implement-product-api` | `/implement [domain] [product\|admin]` | product/admin 통합, 에이전틱 워크플로우 | +| `/implement-test` | `/test [domain] [product\|admin]` | 이름 간소화, 구조 보강 | +| `/verify` | `/verify [quick\|standard\|full]` | 유지 (이미 잘 되어 있음) | +| (없음) | `/define` | **신규** - 요구사항 정의 | +| (없음) | `/plan` | **신규** - 태스크 분해 | +| (없음) | `/debug` | **신규** - 체계적 디버깅 | +| (없음) | `/self-review` | **신규** - 코드 리뷰 | + --- -## 2. 목표 구조 +## 4. 각 스킬 상세 설계 -``` -CLAUDE.md # 경량화: 개요 + 모듈 구조 + 빌드 명령 + 핵심 원칙만 +### 4.1 `/define` - 요구사항 명확화 (신규) -.claude/ -├── settings.json # 훅 설정 (포매팅, 커밋 검증 등) -├── hooks/ -│ ├── session-start.sh # [기존] 원격 환경 Docker -│ ├── post-edit-format.sh # [신규] Edit/Write 후 spotless 실행 -│ └── pre-commit-validate.sh # [신규] 커밋 메시지 규칙 검증 -│ -├── skills/ -│ ├── deploy-batch/ # [기존] 배포 스킬 -│ │ -│ ├── admin-api/ # [신규] /admin-api 스킬 -│ │ ├── SKILL.md # 진입점, Phase 분기 -│ │ └── references/ -│ │ ├── checklist.md # 구현 체크리스트 -│ │ ├── controller.md # 컨트롤러 규칙 -│ │ ├── service.md # 서비스 규칙 -│ │ └── test.md # 테스트 규칙 -│ │ -│ ├── product-api/ # [신규] /product-api 스킬 -│ │ ├── SKILL.md -│ │ └── references/ -│ │ ├── checklist.md -│ │ └── controller.md -│ │ -│ ├── test/ # [신규] /test 스킬 -│ │ ├── SKILL.md -│ │ └── references/ -│ │ ├── unit-test.md # 단위 테스트 (Fake/Stub 패턴) -│ │ ├── integration-test.md # 통합 테스트 (TestContainers) -│ │ └── restdocs-test.md # RestDocs API 문서화 테스트 -│ │ -│ └── domain/ # [신규] /domain 스킬 -│ ├── SKILL.md -│ └── references/ -│ ├── entity.md # 엔티티 작성 규칙 -│ ├── repository.md # 3계층 레포지토리 패턴 -│ └── event.md # 이벤트 기반 아키텍처 -│ -└── docs/ - └── ADMIN-API-GUIDE.md # admin-api 스킬 references로 흡수 후 제거 +**트리거**: "이거 구현해줘", "기능 추가", 모호한 요청 + +**역할**: 코드 작성 전에 무엇을 만드는지 합의 + +```mermaid +flowchart LR + A["모호한 요청"] --> B["가정 표면화"] + B --> C["성공 기준 정의"] + C --> D["영향 범위 분석"] + D --> E["사용자 승인"] ``` ---- +**핵심 요소**: +- 가정(Assumptions) 목록을 먼저 나열 +- 성공 기준을 구체적/테스트 가능하게 정의 +- 영향받는 모듈과 파일 목록 제시 +- 사용자 승인 없이 다음 단계로 넘어가지 않음 -## 3. CLAUDE.md 경량화 방안 +**산출물**: `plan/{feature-name}.md` 파일의 Overview 섹션에 기록 +- `/define` 시점에 plan 문서를 생성하고 Overview에 가정, 성공 기준, 영향 범위를 작성 +- `/plan` 시점에 같은 문서에 Tasks 섹션을 추가 +- 하나의 문서가 define → plan → implement → complete 전체를 추적 -### 유지할 내용 (CLAUDE.md에 남길 것) +**Rationalizations 예시**: -| 섹션 | 이유 | +| 핑계 | 현실 | |------|------| -| 프로젝트 개요 (기술 스택, 아키텍처) | 모든 대화에서 필요한 기본 컨텍스트 | -| 모듈 구조 다이어그램 | 모듈 간 의존성 파악에 필수 | -| 빌드 및 실행 명령어 | 빈번히 참조됨 | -| 핵심 원칙 요약 (5줄 이내) | DDD, 계층 구조, 네이밍 컨벤션 키워드만 | +| "이건 간단해서 스펙 필요 없다" | 간단한 작업에도 수용 기준은 필요하다. 2줄짜리 스펙이면 충분. | +| "코드 짜면서 파악할게" | 그게 바로 삽질의 시작. 15분 스펙이 3시간 재작업을 막는다. | +| "요구사항이 바뀔 텐데" | 그래서 스펙은 살아있는 문서. 없는 것보다 바뀌는 게 낫다. | -### 스킬로 이동할 내용 +--- -| 현재 CLAUDE.md 섹션 | 이동 대상 스킬 | -|---------------------|---------------| -| Admin API 구현 규칙 (~50줄) | `/admin-api` 스킬 | -| 코드 작성 규칙 - 아키텍처 패턴, 네이밍, 예외 처리 | `/domain` 스킬 | -| 테스트 작성 규칙 (~30줄) | `/test` 스킬 | -| 데이터베이스 설계 - 레포지토리 계층 구조 (~60줄) | `/domain` 스킬 references/repository.md | -| 보안 및 인증, 외부 서비스 연동 | `/product-api`, `/admin-api` 스킬에서 필요 시 참조 | +### 4.2 `/plan` - 태스크 분해 (신규) -### 예상 결과 +**트리거**: "계획 세워줘", 3개 이상 파일 변경 예상 시, `/define` 완료 후 -- **현재**: CLAUDE.md ~210줄 (매 대화마다 전체 로딩) -- **목표**: CLAUDE.md ~60줄 (개요만) + 스킬별 on-demand 로딩 +**역할**: 작업을 검증 가능한 작은 단위로 분해 ---- +**핵심 요소**: +- 의존성 그래프 기반 순서 결정 +- 각 태스크에 수용 기준 + 검증 방법 명시 +- Phase 사이에 체크포인트 배치 +- 수직 슬라이스(Vertical Slice) 선호 -## 4. 스킬 설계 +**산출물**: `plan/{feature-name}.md` 파일 1개 -### 4.1 `/admin-api` 스킬 +**플랜 문서 규칙**: +- 하나의 기능 = 하나의 문서 (분할 금지) +- 진행하면서 같은 문서에 상태를 갱신 (Task 완료 시 체크) +- 작업 완료 시 문서 최상단에 `stamp-template.st` 기반 스탬프 추가 -```yaml -# .claude/skills/admin-api/SKILL.md 프론트매터 ---- -name: admin-api -description: | - Admin API 구현 가이드. "어드민 API", "admin api", "관리자 API" 요청 시 사용. - mono 모듈 서비스 작성 -> admin-api 컨트롤러 -> 테스트 순서로 안내. -argument-hint: "[도메인명] [작업유형: crud|search|action]" -allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent ---- +**플랜 문서 라이프사이클**: +```mermaid +flowchart LR + A["plan 생성
Status: IN PROGRESS"] --> B["Task 완료마다
체크 + 기록 갱신"] + B --> C["전체 완료
스탬프 추가
Status: COMPLETED"] + C --> D["plan/complete/
로 이동"] ``` -**트리거 예시**: -- `/admin-api banner crud` - 배너 CRUD 구현 -- `/admin-api curation search` - 큐레이션 검색 API 구현 +**플랜 문서 구조**: +```markdown +# Plan: [기능명] -**동작**: -1. `$ARGUMENTS[0]`(도메인)으로 기존 코드 탐색 (mono 모듈 내 해당 도메인) -2. `$ARGUMENTS[1]`(작업유형)에 따라 체크리스트 분기 -3. 단계별 구현 가이드 제공 (Phase 1: mono, Phase 2: admin-api, Phase 3: test) +## Overview ← /define 시점에 작성 +[무엇을 왜 만드는지] -**핵심**: 기존 `.claude/docs/ADMIN-API-GUIDE.md`의 내용을 references/로 분산 흡수 +### Assumptions +- [가정 1] +- [가정 2] -### 4.2 `/product-api` 스킬 +### Success Criteria +- [성공 기준 1 - 구체적, 테스트 가능] +- [성공 기준 2] + +### Impact Scope +- [영향받는 모듈/파일 목록] + +## Tasks ← /plan 시점에 추가 + +### Task 1: [제목] +- 수용 기준: [구체적, 테스트 가능한 조건] +- 검증: [명령어 또는 확인 방법] +- 파일: [변경 예상 파일 목록] +- 크기: [S | M | L → 분할 필요] +- 상태: [ ] 미완료 / [x] 완료 + +### Task 2: [제목] +... + +## Progress Log ← /implement 진행하면서 갱신 +- [날짜] Task 1 완료 - [커밋 해시 또는 요약] +- [날짜] Task 2 완료 - [커밋 해시 또는 요약] +``` -```yaml ---- -name: product-api -description: | - Product API 구현 가이드. "API 추가", "엔드포인트 구현" 요청 시 사용. - mono 모듈 Facade/Service -> product-api 컨트롤러 -> 테스트 순서로 안내. -argument-hint: "[도메인명] [작업유형]" -allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent --- + +### 4.3 `/implement` - 점진적 구현 (implement-product-api + admin 통합) + +**트리거**: "API 추가", "엔드포인트 구현", "기능 구현" +**인자**: `[domain] [product|admin] [crud|search|action]` + +**현재 `/implement-product-api`와의 차이점**: + +| 항목 | 현재 | 개선 후 | +|------|------|---------| +| 대상 모듈 | product-api만 | product + admin 통합 | +| 구현 단위 | Phase 단위 (대규모) | Slice 단위 (소규모, 반복) | +| 중간 검증 | Phase 끝에만 verify | 매 Slice마다 테스트 실행 | +| 커밋 타이밍 | 전체 완료 후 | Task 완료마다 커밋 (필수) | +| 방어 장치 | 없음 | Rationalizations + Red Flags | + +**Task-Slice-Commit 관계**: + +``` +Task = 커밋 단위 (사용자와 합의한 논리적 목표) +Slice = 실행 단위 (AI가 100줄 넘기 전에 컴파일 확인하는 규율) +Commit = Task 완료 시 반드시 생성 (Slice마다 커밋하지 않음) ``` -**동작**: -1. 도메인 탐색 (mono 모듈) -2. Facade -> Service -> Controller 순서로 구현 가이드 -3. 커서 페이징 패턴 적용 (Admin과 다름) +**검증 수준 구분**: -### 4.3 `/test` 스킬 +| 시점 | 검증 수준 | 실행 내용 | +|------|-----------|-----------| +| **Slice 완료** | 컴파일만 | `compileJava`, `compileKotlin` | +| **Task 완료** | self-review + 단위 테스트 + 커밋 | `/self-review` → `unit_test` + `check_rule_test` → 커밋 | +| **전체 기능 완료** | 통합 테스트 | `integration_test`, `admin_integration_test` | -```yaml ---- -name: test -description: | - 테스트 코드 작성 가이드. "테스트 작성", "test" 요청 시 사용. - 단위/통합/RestDocs 테스트를 구분하여 안내. -argument-hint: "[대상클래스 또는 도메인] [unit|integration|restdocs]" -allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent ---- +**커밋 메시지 형식** (Task = 제목, Slice = 불릿): ``` +feat: rating 통계 API Service 구현 -**트리거 예시**: -- `/test ReviewService unit` - 리뷰 서비스 단위 테스트 -- `/test AdminBannerController restdocs` - 배너 API 문서화 테스트 -- `/test AlcoholService integration` - 주류 서비스 통합 테스트 +- RatingStatisticsResponse DTO 작성 +- RatingRepository에 통계 조회 메서드 추가 +- RatingService.getStatistics() 구현 +- AlcoholFacade 연동 +``` -**동작 분기**: -- `unit`: Fake/Stub 패턴 안내, InMemory 레포지토리 사용 -- `integration`: IntegrationTestSupport 상속, TestContainers, Awaitility -- `restdocs`: @WebMvcTest, MockitoBean, document() 스니펫 생성 +**Task 내부 사이클**: +```mermaid +flowchart LR + A["Slice 구현"] --> B["컴파일 확인"] + B --> C{"통과?"} + C -->|"No"| E["수정"] + E --> B + C -->|"Yes"| D{"마지막
Slice?"} + D -->|"No"| A + D -->|"Yes"| F["/self-review"] + F --> G["unit_test +
check_rule_test"] + G --> H["Task 커밋"] +``` -### 4.4 `/domain` 스킬 +**모듈별 분기**: +```mermaid +flowchart TD + start["'/implement rating product crud'"] --> explore["Phase 0: Explore
기존 코드 탐색 + 영향 분석"] + explore --> module_check{"모듈?"} + + module_check -->|"product"| mono_java["Phase 1: Mono
Java 비즈니스 로직"] + module_check -->|"admin"| mono_admin["Phase 1: Mono
Java 비즈니스 로직
(Admin 전용 서비스)"] + + mono_java --> product_ctrl["Phase 2: Controller
Java @RestController
'/api/v1/{domain}'"] + mono_admin --> admin_ctrl["Phase 2: Controller
Kotlin @RestController
context-path: /admin/api/v1"] + + product_ctrl --> verify_q["Phase 3: /verify quick"] + admin_ctrl --> verify_q +``` + +**references 분리 (프로젝트 패턴은 별도 파일)**: +- `references/mono-patterns.md` - Repository 3-Tier, Facade, DTO 패턴 (기존 유지) +- `references/product-api-patterns.md` - Product 컨트롤러 패턴 (기존에서 분리) +- `references/admin-api-patterns.md` - Admin 컨트롤러 패턴 (ADMIN-API-GUIDE.md 흡수) -```yaml ---- -name: domain -description: | - 도메인 모델 구현 가이드. "엔티티 추가", "도메인 설계", "레포지토리" 요청 시 사용. - 엔티티/레포지토리 3계층/이벤트 패턴을 안내. -argument-hint: "[도메인명] [entity|repository|event]" -allowed-tools: Read, Edit, Write, Bash, Glob, Grep, Agent --- + +### 4.4 `/test` - 테스트 작성 (기존 implement-test 보강) + +**트리거**: "테스트 작성", "테스트 추가", `/implement` 완료 후 +**인자**: `[domain] [product|admin] [unit|integration|restdocs|all]` + +**변경사항**: 이름 간소화 + 에이전틱 구조 추가 + 테스트 실행 시점 명확화 + +기존 Phase 구조는 유지하되 추가: +- Rationalizations 테이블 +- Red Flags 섹션 +- Verification 체크리스트 + +**테스트 종류별 역할과 실행 시점**: + +| 테스트 종류 | 태그 | 작성 시점 | 실행 시점 | 실행 명령 | +|------------|------|-----------|-----------|-----------| +| **단위 테스트** | `@Tag("unit")` | `/implement` Task와 함께 | Task 커밋 전 | `./gradlew unit_test` | +| **아키텍처 규칙** | `@Tag("rule")` | 이미 존재 (ArchUnit) | Task 커밋 전 | `./gradlew check_rule_test` | +| **통합 테스트** | `@Tag("integration")` | 전체 기능 완료 후 | `/verify full` | `./gradlew integration_test` | +| **Admin 통합** | `@Tag("admin_integration")` | 전체 기능 완료 후 | `/verify full` | `./gradlew admin_integration_test` | +| **RestDocs** | (없음) | 사용자 요청 시만 | 문서화 필요 시 | `./gradlew restDocsTest` | + +**테스트 작성 패턴 선택 기준**: + +``` +새 테스트 작성 시: +├── 서비스 로직 테스트? +│ ├── Fake/InMemory 구현체가 있는가? +│ │ ├── Yes → Fake 패턴 사용 (기존 InMemory 활용) +│ │ └── No → InMemory 구현체 먼저 생성 → Fake 패턴 +│ └── Mockito는 최후의 수단 (사용자 확인 필수) +├── API 엔드포인트 테스트? +│ ├── product → IntegrationTestSupport + mockMvcTester +│ └── admin → IntegrationTestSupport + mockMvcTester (Kotlin) +└── API 문서화? + └── RestDocs (사용자 명시적 요청 시만) ``` -**동작 분기**: -- `entity`: BaseEntity 상속, @Embeddable 복합키, Hibernate @Filter -- `repository`: 3계층 (DomainRepository -> JpaRepository -> QueryDSL Custom) -- `event`: ApplicationEventPublisher, @TransactionalEventListener, @Async +**`/implement`와의 관계**: +- `/implement` 안에서 Slice 단위로 **기존 테스트를 실행**하는 것 = 컴파일 확인 +- `/test`를 별도 호출하는 것 = **새로운 테스트 코드를 작성**하는 것 +- 단위 테스트는 `/implement` Task와 함께 작성하는 것이 이상적 +- 통합 테스트는 전체 기능 완료 후 `/test`로 별도 작성 --- -## 5. 훅 설계 - -### 5.1 PostToolUse: 자동 포매팅 [구현 완료] - -**목적**: Claude가 Java/Kotlin 파일을 수정하면 자동으로 spotless 적용 - -**구현 방식**: 별도 스크립트 없이 settings.json 인라인 명령어로 구현 - -```json -{ - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "FP=$(cat | jq -r '.tool_input.file_path // empty'); [[ \"$FP\" == *.java || \"$FP\" == *.kt ]] && cd $CLAUDE_PROJECT_DIR && ./gradlew spotlessApply -q 2>/dev/null || true", - "timeout": 30 - } - ] - } - ] -} -``` +### 4.5 `/debug` - 체계적 디버깅 (신규) + +**트리거**: "에러 났어", "테스트 실패", "빌드 안 돼", "왜 안 되지" -**동작**: -1. stdin JSON에서 `tool_input.file_path` 추출 -2. `.java` 또는 `.kt` 파일인 경우에만 spotless 실행 -3. 그 외 파일(.md, .gradle, .xml 등)은 스킵 - -**빌드 설정 (build.gradle)**: -- `bottlenote-mono`, `bottlenote-product-api`: google-java-format (기존) -- `bottlenote-admin-api`: ktlint 추가 (`no-wildcard-imports` 규칙 비활성화) - -**검증 결과**: -- .java 파일: 인덴트/공백 위반 자동 복원 확인 (MD5 해시 일치) -- .kt 파일: 인덴트/공백 위반 자동 복원 확인 (MD5 해시 일치) -- .gradle 파일: Hook 미실행 확인 (의도대로 동작) - -**참고**: -- 초기에 별도 스크립트(post-edit-format.sh)로 모듈별 분기 로직을 구현했으나, 인라인 명령어가 더 간결하여 채택 -- ktlint의 `no-wildcard-imports` 규칙은 auto-fix 불가 (클래스패스 분석 필요) → 비활성화 처리 - -### 5.2 PreToolUse: 커밋 메시지 검증 - -**목적**: 커밋 메시지가 `타입: 제목` 형식을 따르는지 검증 - -```json -{ - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-validate.sh", - "timeout": 5 - } - ] - } - ] -} +**5단계 프로세스**: +```mermaid +flowchart LR + A["STOP
작업 중단"] --> B["REPRODUCE
재현"] + B --> C["LOCALIZE
위치 특정"] + C --> D["FIX
근본 원인 수정"] + D --> E["GUARD
회귀 테스트 추가"] + E --> F["VERIFY
전체 검증"] ``` -**pre-commit-validate.sh 동작**: -1. stdin JSON에서 `tool_input.command` 추출 -2. `git commit` 명령인지 확인 (아니면 즉시 통과) -3. 커밋 메시지 추출 후 정규식 검증: `^(feat|fix|refactor|test|docs|chore): .{1,50}$` -4. 실패 시 exit 2 (BLOCK) + stderr로 피드백 메시지 출력 - -### 5.3 훅 설정 종합 (settings.json 최종 형태) - -```json -{ - "hooks": { - "SessionStart": [ - { - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" - } - ] - } - ], - "PostToolUse": [ - { - "matcher": "Edit|Write", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/post-edit-format.sh", - "timeout": 30, - "statusMessage": "spotless 포매팅 적용 중..." - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-validate.sh", - "timeout": 5 - } - ] - } - ] - } -} +**프로젝트 특화 분기**: +``` +빌드 실패: +├── Java 컴파일 에러 → mono/product-api 소스 확인 +├── Kotlin 컴파일 에러 → admin-api 소스 확인 +├── Spotless 포맷 에러 → ./gradlew spotlessApply +├── 테스트 실패 +│ ├── @Tag("unit") → Fake/InMemory 구현체 확인 +│ ├── @Tag("integration") → TestContainers/Docker 상태 확인 +│ ├── @Tag("rule") → ArchUnit 아키텍처 규칙 위반 확인 +│ └── @Tag("admin_integration") → Admin 인증 설정 확인 +└── 의존성 에러 → libs.versions.toml 확인 ``` --- -## 6. 구현 로드맵 +### 4.6 `/self-review` - 코드 리뷰 (신규) + +**트리거**: "리뷰해줘", 각 Task 커밋 전, 기능 완료 후 + +**5축 리뷰 (프로젝트 맞춤화)**: + +| 축 | 이 프로젝트에서의 체크 포인트 | +|----|---------------------------| +| **정확성** | 스펙 일치, 에지 케이스, 예외 처리 | +| **가독성** | 네이밍 컨벤션, 한글 DisplayName, 주석 간결성 | +| **아키텍처** | Facade 경계, Repository 3-Tier, 도메인 분리 | +| **보안** | SecurityContextUtil 인증, 입력 검증, SQL 파라미터화 | +| **성능** | N+1 쿼리, 페이징 누락, 캐시 전략 | + +**심각도 레이블**: +- **Critical**: 머지 차단 (보안 취약점, 데이터 손실, 기능 장애) +- **Important**: 머지 전 수정 권장 (누락 테스트, 잘못된 추상화) +- **Nit**: 선택 사항 (네이밍, 스타일) + +--- + +## 5. 통합 파일 구조 (개선 후) -### Phase 1: CLAUDE.md 경량화 +``` +.claude/ +├── settings.json # 훅 설정 (기존 유지) +├── settings.local.json # 로컬 권한 (기존 유지) +├── hooks/ +│ └── session-start.sh # Docker 설정 (기존 유지) +├── docs/ +│ └── ADMIN-API-GUIDE.md # → references로 이동 예정 +├── skills/ +│ ├── define/ # [신규] 요구사항 명확화 +│ │ └── SKILL.md +│ ├── plan/ # [신규] 태스크 분해 +│ │ └── SKILL.md +│ ├── implement/ # [통합] product + admin 구현 +│ │ ├── SKILL.md +│ │ └── references/ +│ │ ├── mono-patterns.md # 기존 유지 +│ │ ├── product-patterns.md # 기존에서 분리 +│ │ └── admin-patterns.md # ADMIN-API-GUIDE.md 흡수 +│ ├── test/ # [보강] 이름 간소화 +│ │ ├── SKILL.md +│ │ └── references/ +│ │ ├── test-infra.md # 기존 유지 +│ │ └── test-patterns.md # 기존 유지 +│ ├── verify/ # 기존 유지 +│ │ └── SKILL.md +│ ├── debug/ # [신규] 체계적 디버깅 +│ │ └── SKILL.md +│ └── self-review/ # [신규] 커밋 전 self-review +│ └── SKILL.md +└── ... +``` -- [ ] CLAUDE.md에서 스킬로 이동할 섹션 식별 및 분리 -- [ ] 경량화된 CLAUDE.md 작성 (~60줄) -- [ ] 기존 내용 백업 +--- -### Phase 2: 스킬 구현 +## 6. 모든 스킬의 공통 뼈대 -- [ ] `/admin-api` 스킬 (기존 ADMIN-API-GUIDE.md 흡수) -- [ ] `/test` 스킬 -- [ ] `/product-api` 스킬 -- [ ] `/domain` 스킬 -- [ ] 각 스킬 테스트 (실제 트리거 및 동작 확인) +agent-skills에서 차용하는 일관된 SKILL.md 구조: -### Phase 3: 훅 구현 +### 언어 규칙 -- [x] PostToolUse 자동 포매팅 훅 (인라인 명령어 방식, .java + .kt 대응) -- [x] build.gradle에 admin-api Kotlin spotless 설정 추가 (ktlint) -- [x] 훅 동작 검증 (.java, .kt, .gradle 3종 케이스 MD5 해시 비교) -- [ ] `pre-commit-validate.sh` 작성 및 테스트 -- [ ] settings.json에 PreToolUse 커밋 메시지 검증 훅 추가 +**SKILL.md, references 등 스킬 정의 문서는 영어로 작성한다.** -### Phase 4: 검증 및 조정 +한국어를 사용하는 경우는 다음으로 제한: +- 커밋 메시지 (타입 접두사는 영어, 제목/본문은 한국어) +- `plan/*.md` 플랜 문서 +- 사용자와의 대화 응답 +- `@DisplayName` 등 한국어가 관례인 코드 요소 +- description의 트리거 키워드 (한국어 트리거는 한국어로 표기) -- [ ] 실제 작업 시나리오 테스트 (Admin API 1개 구현해보기) -- [ ] 컨텍스트 윈도우 사용량 비교 (전/후) -- [ ] 스킬 트리거 정확도 확인 -- [ ] 훅 오탐/미탐 확인 +### SKILL.md 구조 +```markdown +--- +name: skill-name +description: | + One-line description. Trigger conditions. + Trigger: "/command", or when the user says "한국어 트리거", "English trigger". +argument-hint: "[arg description]" --- -## 7. 의사결정 필요 사항 +# Skill Title + +## Overview +Why this skill exists and what it does. + +## When to Use +- Condition 1 +- Condition 2 + +## When NOT to Use +- Condition 1 -| # | 질문 | 선택지 | 메모 | -|---|------|--------|------| -| 1 | spotless를 PostToolUse에서 실행할지, 커밋 전에만 실행할지 | A: 매 편집마다 / B: 커밋 전만 | **결정: A** - 매 편집마다 실행, .java/.kt 파일만 필터링하여 불필요한 실행 방지 | -| 2 | 스킬 간 공통 규칙(네이밍, 예외 처리)을 어디에 둘지 | A: CLAUDE.md / B: 별도 공통 스킬 / C: 각 스킬에 중복 | A가 가장 단순 | -| 3 | admin-api 스킬에 context: fork를 적용할지 | A: fork (독립 컨텍스트) / B: 기본 (대화 공유) | fork면 대화 이력 참조 불가 | -| 4 | Kotlin 파일 포매팅은 어떻게 할지 | A: ktlint / B: ktfmt / C: 당분간 제외 | **���정: A** - ktlint 채택, `no-wildcard-imports` 규칙만 비활성화 (auto-fix 불가) | +## Process +Step-by-step workflow (the core of the skill). + +## Common Rationalizations +| Rationalization | Reality | +|-----------------|---------| +| "..." | "..." | + +## Red Flags +- Signal that something is going wrong + +## Verification +- [ ] Exit criteria checklist +``` --- -## 8. 기대 효과 +## 7. 전체 워크플로우 시나리오 예시 + +### 시나리오: "rating 도메인에 평점 통계 API 추가" + +```mermaid +sequenceDiagram + actor User as 사용자 + participant Define as /define + participant Plan as /plan + participant Impl as /implement + participant Test as /test + participant Verify as /verify + participant SR as /self-review + participant Git as git commit + + %% DEFINE + User->>Define: "rating 통계 API 추가해줘" + Define->>Define: plan/rating-statistics.md 생성
(Overview + Assumptions + Success Criteria) + Define->>User: 가정 목록 + 성공 기준 제시 + User->>Define: 승인 + + %% PLAN + Define->>Plan: 같은 문서에 Tasks 추가 + Plan->>User: T1: DTO + Repository
T2: Service + 단위테스트
T3: Controller
T4: 통합테스트 + User->>Plan: 승인 + + %% IMPLEMENT - Task 1 + rect rgb(240, 248, 255) + Note over Impl,Git: Task 1: DTO + Repository + Impl->>Impl: Slice 1: DTO 작성 → compileJava + Impl->>Impl: Slice 2: Repository 추가 → compileJava + Impl->>SR: self-review (5축) + SR->>Verify: unit_test + check_rule_test + Verify->>Git: feat: rating 통계 DTO 및 Repository 추가 + end + + %% IMPLEMENT - Task 2 + rect rgb(240, 255, 240) + Note over Impl,Git: Task 2: Service + 단위테스트 + Impl->>Impl: Slice 1: Service 구현 → compileJava + Impl->>Impl: Slice 2: 단위테스트 작성 → compileJava + Impl->>SR: self-review (5축) + SR->>Verify: unit_test + check_rule_test + Verify->>Git: feat: rating 통계 Service 구현 + end + + %% IMPLEMENT - Task 3 + rect rgb(255, 248, 240) + Note over Impl,Git: Task 3: Controller + Impl->>Impl: Slice 1: Controller 작성 → compileJava + Impl->>SR: self-review (5축) + SR->>Verify: unit_test + check_rule_test + Verify->>Git: feat: rating 통계 API 엔드포인트 추가 + end + + %% TEST - Task 4 + rect rgb(248, 240, 255) + Note over Test,Git: Task 4: 통합테스트 + Test->>Test: 통합 테스트 작성 + Test->>SR: self-review + SR->>Verify: /verify full (integration_test 포함) + Verify->>Git: test: rating 통계 API 통합 테스트 추가 + end + + %% COMPLETE + Git->>User: plan 문서에 스탬프 추가 +``` -| 지표 | 현재 | 목표 | -|------|------|------| -| CLAUDE.md 크기 | ~210줄 (항상 로딩) | ~60줄 (개요만) | -| 구현 가이드 접근 | 수동 (대화로 설명) | `/admin-api banner crud` 한 줄 | -| 코드 포매팅 | 수동 spotlessApply | 자동 (훅) | -| 커밋 메시지 검증 | 없음 | 자동 (훅) | -| 새 기능 구현 시간 | 매번 규칙 재설명 필요 | 스킬이 컨텍스트 제공 | +--- + +## 8. 구현 순서 (우선순위) + +| 순서 | 작업 | 난이도 | 효과 | +|------|------|--------|------| +| **1** | 공통 뼈대 확정 (Rationalizations + Red Flags + Verification) | 낮음 | 높음 | +| **2** | `/implement` 스킬 작성 (implement-product-api + admin 통합) | 중간 | 높음 | +| **3** | `/test` 스킬 보강 (implement-test 리네이밍 + 구조 보강) | 낮음 | 중간 | +| **4** | `/debug` 스킬 작성 | 중간 | 높음 | +| **5** | `/self-review` 스킬 작성 | 중간 | 중간 | +| **6** | `/define` 스킬 작성 | 낮음 | 중간 | +| **7** | `/plan` 스킬 작성 | 낮음 | 중간 | +| **8** | CLAUDE.md 업데이트 (새 스킬 체계 반영) | 낮음 | 높음 | --- -## 참고 자료 +## 9. 결정 사항 -- Claude Code Hooks 공식 문서: https://code.claude.com/docs/en/hooks -- Claude Code Skills 공식 문서: https://code.claude.com/docs/en/skills -- 프론트엔드 스킬 구조: `bottle-note-frontend/.claude/skills/Code/` -- 기존 배포 스킬: `.claude/skills/deploy-batch/` +- [x] `/implement`에서 batch 모듈은 **제외** (product + admin만) +- [x] `/self-review`는 **커밋 전 self-review 전용** (PR 리뷰는 범위 밖) +- [x] 기존 스킬(`implement-product-api`, `implement-test`)은 **전체 완료 후 제거** (deprecated로 유지) +- [x] CLAUDE.md의 Admin/Product 구현 규칙은 `references/`로 **이동**, CLAUDE.md는 프로젝트 개요 중심으로 재구성 +- [x] 스킬 문서(SKILL.md, references)는 **영어**로 작성. 한국어는 커밋 메시지, plan 문서, 대화 응답, @DisplayName 등으로 제한 From a2945e59e29ec1d869f6f527343fbb43c6a5ad91 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 21:00:38 +0900 Subject: [PATCH 25/31] chore: remove deprecated implement-product-api and implement-test skills - Replaced by /implement (unified product+admin) and /test (enhanced) - All content preserved in new skill files and references Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/implement-product-api/SKILL.md | 145 ----------- .../references/mono-patterns.md | 246 ------------------ .claude/skills/implement-test/SKILL.md | 151 ----------- .../implement-test/references/test-infra.md | 149 ----------- .../references/test-patterns.md | 230 ---------------- 5 files changed, 921 deletions(-) delete mode 100644 .claude/skills/implement-product-api/SKILL.md delete mode 100644 .claude/skills/implement-product-api/references/mono-patterns.md delete mode 100644 .claude/skills/implement-test/SKILL.md delete mode 100644 .claude/skills/implement-test/references/test-infra.md delete mode 100644 .claude/skills/implement-test/references/test-patterns.md diff --git a/.claude/skills/implement-product-api/SKILL.md b/.claude/skills/implement-product-api/SKILL.md deleted file mode 100644 index 80a75df73..000000000 --- a/.claude/skills/implement-product-api/SKILL.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -name: implement-product-api -description: | - Product API feature implementation guide for the bottle-note-api-server project. - Trigger: "/implement-product-api", or when the user says "API 추가", "엔드포인트 구현", "product api", "클라이언트 API". - Guides through the implementation flow: mono module (domain/service) -> product-api (controller). - For test implementation, use /implement-test after this skill completes. - Always use this skill when implementing new features or endpoints in the product-api module. -argument-hint: "[domain] [crud|search|action]" ---- - -# Product API Implementation Guide - -This skill guides you through implementing new features in the product-api module. The project follows a DDD-based multi-module architecture where business logic lives in `bottlenote-mono` and controllers live in `bottlenote-product-api`. - -## Workflow - -Parse `$ARGUMENTS` to identify the target domain and work type, then follow these phases: - -### Phase 0: Explore - -Before writing any code, understand what already exists and what will be affected. - -**Codebase scan:** -1. Check if the domain exists in mono: `bottlenote-mono/src/main/java/app/bottlenote/{domain}/` -2. Check existing services, facades, and repositories -3. Check if product-api already has controllers for this domain -4. Identify reusable code vs. what needs to be created - -**Impact analysis:** -5. **Events** - related domain events (publish/subscribe), whether new events are needed -6. **Transactions** - propagation policy when calling across Facades, need for `@Async` separation -7. **Ripple scope** - files affected if Repository/Facade interfaces change (other services, InMemory implementations, tests) -8. **Cache** - whether the target data is cached (`@Cacheable`, Caffeine, Redis), invalidation strategy needed -9. **Schema** - whether Entity changes require a Liquibase migration - -Report both findings and impact to the user before proceeding. - -### Phase 1: Mono Module (Domain & Business Logic) - -All business logic belongs in `bottlenote-mono`. Read `references/mono-patterns.md` for detailed patterns. - -**Order of implementation:** -1. **Entity/Domain** (if new domain) - `{domain}/domain/` -2. **Repository** (3-tier) - Domain repo -> JPA repo -> QueryDSL (if needed) -3. **DTO** - Request/Response records in `{domain}/dto/request/`, `{domain}/dto/response/` -4. **Exception** - `{domain}/exception/{Domain}Exception.java` + `{Domain}ExceptionCode.java` -5. **Service** - `{Domain}Service` (Command/Query 분리는 필수가 아님, 아래 참고) -6. **Facade** (타 도메인 접근이 필요할 때) - `{Domain}Facade` interface + `Default{Domain}Facade` - -**Service structure:** -- Command/Query split (`CommandService`/`QueryService`) exists in codebase but is not mandatory -- New services can be a single `{Domain}Service` -- No need to merge existing split services; decide per new implementation - -**Facade role - protecting domain boundaries:** -- Facade prevents direct cross-domain service calls; request through the target domain's Facade instead -- Example: UserService must not query ranking data directly; use `RankingFacade` -- This allows each domain to freely change its internal implementation -- Facade interface is the contract a domain exposes to the outside - -### Phase 2: Product API (Controller) - -Controllers in product-api are thin - they delegate to mono services/facades. - -**Controller structure:** -```java -@Slf4j -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/{domain}") -public class {Domain}Controller { - - private final {Domain}CommandService commandService; - private final {Domain}QueryService queryService; - - @GetMapping - public ResponseEntity list(@ModelAttribute PageableRequest request) { - Long userId = SecurityContextUtil.getUserIdByContext().orElse(-1L); - // ... - return GlobalResponse.ok(response, metaInfos); - } - - @PostMapping - public ResponseEntity create( - @RequestBody @Valid CreateRequest request - ) { - Long userId = SecurityContextUtil.getUserIdByContext() - .orElseThrow(() -> new UserException(UserExceptionCode.REQUIRED_USER_ID)); - return GlobalResponse.ok(commandService.create(request, userId)); - } -} -``` - -**Key rules:** -- Path: `@RequestMapping("/api/v1/{plural-resource}")` -- Auth required: `SecurityContextUtil.getUserIdByContext().orElseThrow(...)` -- Auth optional (read): `.orElse(-1L)` -- Response: Always wrap with `GlobalResponse.ok()` -- Pagination: Use `CursorPageable` + `PageResponse` + `MetaInfos` - -### Phase 3: Verify - -Use the `/verify` skill to validate: - -| Timing | Command | What it checks | -|--------|---------|----------------| -| After Phase 1 (Mono) | `/verify quick` | Compile + architecture rules | -| After Phase 2 (Controller) | `/verify quick` | Compile + architecture rules | -| Before push/PR | `/verify full` | Full CI including integration tests | - -### Next: Tests - -After implementation is verified, use `/implement-test {domain} product` to create tests. - -## Endpoint Design - -| HTTP Method | Purpose | URL Pattern | Example | -|-------------|---------|-------------|---------| -| GET | List | `/api/v1/{resources}` | `GET /api/v1/reviews` | -| GET | Detail | `/api/v1/{resources}/{id}` | `GET /api/v1/reviews/1` | -| POST | Create | `/api/v1/{resources}` | `POST /api/v1/reviews` | -| PUT | Full update | `/api/v1/{resources}/{id}` | `PUT /api/v1/reviews/1` | -| PATCH | Partial update | `/api/v1/{resources}/{id}` | `PATCH /api/v1/reviews/1` | -| DELETE | Delete | `/api/v1/{resources}/{id}` | `DELETE /api/v1/reviews/1` | - -## Package Structure Reference - -``` -bottlenote-mono/src/main/java/app/bottlenote/{domain}/ -├── constant/ # Enums, constants -├── domain/ # Entities, DomainRepository interface -├── dto/ -│ ├── request/ # Request records (@Valid) -│ ├── response/ # Response records -│ └── dsl/ # QueryDSL criteria -├── event/ # Domain events -├── exception/ # {Domain}Exception + {Domain}ExceptionCode -├── facade/ # {Domain}Facade interface -├── repository/ # Jpa{Domain}Repository, Custom{Domain}Repository -└── service/ # {Domain}CommandService, {Domain}QueryService - -bottlenote-product-api/src/main/java/app/bottlenote/{domain}/ -└── controller/ # {Domain}Controller (thin, delegates to mono) -``` diff --git a/.claude/skills/implement-product-api/references/mono-patterns.md b/.claude/skills/implement-product-api/references/mono-patterns.md deleted file mode 100644 index d6cfbd9d6..000000000 --- a/.claude/skills/implement-product-api/references/mono-patterns.md +++ /dev/null @@ -1,246 +0,0 @@ -# Mono Module Patterns - -## Repository 3-Tier Pattern - -### 1. Domain Repository (Required) -Pure business interface - no Spring/JPA dependency. - -```java -// Location: {domain}/domain/{Domain}Repository.java -@DomainRepository -public interface RatingRepository { - Rating save(Rating rating); - Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId); -} -``` - -### 2. JPA Repository (Required) -Implements domain repo + extends JpaRepository. - -```java -// Location: {domain}/repository/Jpa{Domain}Repository.java -@JpaRepositoryImpl -public interface JpaRatingRepository - extends JpaRepository, RatingRepository, CustomRatingRepository { -} -``` - -### 3. QueryDSL Custom Repository (Optional - complex queries only) - -```java -// Interface: {domain}/repository/Custom{Domain}Repository.java -public interface CustomRatingRepository { - PageResponse fetchRatingList(RatingListFetchCriteria criteria); -} - -// Implementation: {domain}/repository/Custom{Domain}RepositoryImpl.java -public class CustomRatingRepositoryImpl implements CustomRatingRepository { - // JPAQueryFactory injection, BooleanBuilder for dynamic conditions -} - -// Query supporter: {domain}/repository/{Domain}QuerySupporter.java -@Component -public class RatingQuerySupporter { - // Reusable query fragments -} -``` - -**Use QueryDSL only for:** dynamic multi-condition filters, multi-table joins, complex projections. -**Do NOT use for:** simple CRUD, single-condition lookups (use method query or @Query JPQL). - -## Service Pattern - -서비스는 `{Domain}Service` 하나로 작성하는 것이 기본이다. -기존 코드에 Command/Query 분리(`CommandService`/`QueryService`)가 있지만 필수 패턴이 아니며, 신규 구현 시 하나로 작성해도 됨. 기존 분리된 서비스를 굳이 합칠 필요는 없음. - -```java -@Service -@RequiredArgsConstructor -public class RatingService { - private final RatingRepository ratingRepository; - private final AlcoholFacade alcoholFacade; // 타 도메인 접근은 반드시 Facade를 통해 - - @Transactional - public RatingRegisterResponse register(Long alcoholId, Long userId, RatingPoint ratingPoint) { - Objects.requireNonNull(alcoholId, "alcoholId must not be null"); - - // 타 도메인 검증 - AlcoholFacade를 통해 요청 - if (FALSE.equals(alcoholFacade.existsByAlcoholId(alcoholId))) { - throw new RatingException(RatingExceptionCode.ALCOHOL_NOT_FOUND); - } - - // 자기 도메인 로직 - Rating rating = ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId) - .orElse(Rating.builder().alcoholId(alcoholId).userId(userId).build()); - rating.registerRatingPoint(ratingPoint); - ratingRepository.save(rating); - - // 이벤트 발행 (부수 효과) - eventPublisher.publishEvent(new RatingRegistryEvent(alcoholId, userId)); - - return new RatingRegisterResponse(rating.getId()); - } - - @Transactional(readOnly = true) - public PageResponse fetchList(RatingListFetchCriteria criteria) { - // 읽기 전용 메서드는 readOnly = true - } -} -``` - -## Aggregate Root & Facade Pattern - -### Aggregate 개념 - -도메인은 Aggregate 단위로 묶인다. 절대적인 규칙은 아니지만 개념적 경계로 활용한다. - -``` -ranking (Aggregate Root) -├── RankingService ← 외부에서 접근 가능 (Facade를 통해) -├── RankingPointService ← 내부 구현, 외부 접근 불가 -├── RankingHistoryService ← 내부 구현, 외부 접근 불가 -└── RankingFacade ← 외부에 노출하는 유일한 창구 -``` - -외부 도메인은 Aggregate Root(= Facade)를 통해서만 접근한다. Aggregate 내부의 하위 서비스에 직접 접근하면 안 된다. - -``` -[OK] UserService → RankingFacade (Aggregate Root 접근) -[OK] UserProfileService → RankingFacade (Aggregate Root 접근) -[NO] UserService → RankingPointService (하위 도메인 직접 접근) -[NO] UserProfileService → RankingHistoryService (하위 도메인 직접 접근) -``` - -### Facade의 역할 - -Facade는 Aggregate Root로서 도메인 간 경계를 보호한다. - -**왜 필요한가:** -- UserService가 RankingPointService를 직접 호출하면, Ranking 내부 구조 변경 시 UserService도 깨짐 -- RankingFacade를 통해 요청하면, Ranking이 내부 구현(서비스 분리, 테이블 구조, 캐시 전략)을 자유롭게 변경 가능 -- Facade 인터페이스는 해당 도메인이 외부에 노출하는 계약(contract) - -**원칙:** -- 같은 Aggregate 내에서는 Repository/Service를 직접 사용 -- 다른 Aggregate의 데이터가 필요하면 반드시 해당 Aggregate의 Facade를 통해 접근 -- Facade는 외부에 필요한 최소한의 인터페이스만 노출 - -``` -[OK] UserService → UserRepository (같은 Aggregate) -[OK] UserService → AlcoholFacade (타 Aggregate의 Facade) -[NO] UserService → AlcoholRepository (타 Aggregate 직접 접근) -[NO] UserService → RankingPointService (타 Aggregate 하위 서비스 직접 호출) -``` - -```java -// Interface: {domain}/facade/{Domain}Facade.java -public interface AlcoholFacade { - Boolean existsByAlcoholId(Long alcoholId); - AlcoholInfo getAlcoholInfo(Long alcoholId); -} - -// Implementation: {domain}/service/Default{Domain}Facade.java -@FacadeService -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class DefaultAlcoholFacade implements AlcoholFacade { - private final AlcoholRepository alcoholRepository; - // 자기 도메인의 Repository만 사용 -} -``` - -## DTO Patterns - -```java -// Request with validation -public record RatingRegisterRequest( - @NotNull Long alcoholId, - @NotNull Double rating -) {} - -// Pageable request with defaults -public record ReviewPageableRequest( - ReviewSortType sortType, SortOrder sortOrder, Long cursor, Long pageSize -) { - @Builder - public ReviewPageableRequest { - sortType = sortType != null ? sortType : ReviewSortType.POPULAR; - cursor = cursor != null ? cursor : 0L; - pageSize = pageSize != null ? pageSize : 10L; - } -} - -// Response with factory method -public record RatingListFetchResponse(Long totalCount, List ratings) { - public record Info(Long ratingId, Long alcoholId, Double rating) {} - public static RatingListFetchResponse create(Long total, List infos) { - return new RatingListFetchResponse(total, infos); - } -} -``` - -## Exception Pattern - -```java -// {domain}/exception/{Domain}Exception.java -public class RatingException extends AbstractCustomException { - public RatingException(RatingExceptionCode code) { - super(code); - } -} - -// {domain}/exception/{Domain}ExceptionCode.java -@Getter -public enum RatingExceptionCode implements ExceptionCode { - INVALID_RATING_POINT(HttpStatus.BAD_REQUEST, "invalid rating point"), - ALCOHOL_NOT_FOUND(HttpStatus.NOT_FOUND, "alcohol not found"); - - private final HttpStatus httpStatus; - private final String message; - - RatingExceptionCode(HttpStatus httpStatus, String message) { - this.httpStatus = httpStatus; - this.message = message; - } -} -``` - -## Event Pattern - -```java -// Event record -public record RatingRegistryEvent(Long alcoholId, Long userId) {} - -// Listener -@DomainEventListener -@RequiredArgsConstructor -public class RatingEventListener { - @TransactionalEventListener - @Async - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void handleRatingRegistry(RatingRegistryEvent event) { - // Side effects in separate transaction - } -} -``` - -## Cursor Pagination - -```java -// In service/repository -public PageResponse fetchList(Criteria criteria) { - List items = queryFactory.selectFrom(...) - .where(cursorCondition(criteria.cursor())) - .limit(criteria.pageSize() + 1) // fetch one extra to detect hasNext - .fetch(); - - CursorPageable pageable = CursorPageable.of(items, criteria.cursor(), criteria.pageSize()); - return PageResponse.of(items.subList(0, Math.min(items.size(), criteria.pageSize())), pageable); -} - -// In controller -MetaInfos metaInfos = MetaService.createMetaInfo(); -metaInfos.add("pageable", response.cursorPageable()); -metaInfos.add("searchParameters", request); -return GlobalResponse.ok(response, metaInfos); -``` diff --git a/.claude/skills/implement-test/SKILL.md b/.claude/skills/implement-test/SKILL.md deleted file mode 100644 index 0c25f0f5f..000000000 --- a/.claude/skills/implement-test/SKILL.md +++ /dev/null @@ -1,151 +0,0 @@ ---- -name: implement-test -description: | - Test implementation guide for bottle-note-api-server (product-api & admin-api). - Trigger: "/implement-test", or when the user says "테스트 작성", "테스트 구현", "테스트 추가", "write tests", "implement tests". - Guides through unit test (Fake/Stub), integration test, and RestDocs test creation. - Supports both product-api (Java) and admin-api (Kotlin) modules. -argument-hint: "[domain] [product|admin] [unit|integration|restdocs|all]" ---- - -# Test Implementation Guide - -References: -- `references/test-infra.md` - shared test utilities, TestContainers, existing Fake/InMemory list -- `references/test-patterns.md` - unit, integration, RestDocs code patterns - -## Argument Parsing - -Parse `$ARGUMENTS` to determine: -- **domain**: target domain (e.g., `alcohols`, `rating`, `review`) -- **module**: `product` (default) or `admin` -- **scope**: `unit`, `integration`, `restdocs`, or `all` (default: `unit` + `integration`) - -## Phase 0: Explore - -Before writing tests, understand the implementation: - -1. Read the service class to identify testable methods and branches -2. Check existing test infrastructure: - - Fake/InMemory repositories for the domain - - TestFactory for the domain - - ObjectFixture for the domain -3. Report findings: what exists, what needs to be created - -## Phase 1: Scenario Definition - -Define test scenario lists based on service methods and API endpoints. -Write each scenario in `@DisplayName` format (`when ~, should ~`) and get user approval before implementation. - -**Unit test scenarios** (per service method): -- Success: expected behavior with valid input -- Failure: exception conditions (not found, unauthorized, duplicate, etc.) -- Edge cases: null, empty, boundary values - -**Integration test scenarios** (per API endpoint): -- Authenticated request + successful response -- Authentication failure (401) -- Business validation failure (400, 404, 409, etc.) - -Example output (scenarios must be written in Korean for `@DisplayName`): -``` -Unit: RatingService -- 유효한 요청이면 평점을 등록할 수 있다 -- 존재하지 않는 주류에 평점을 등록하면 예외가 발생한다 -- 이미 평점이 있으면 기존 평점을 갱신한다 -- 평점 등록 시 이벤트가 발행된다 - -Integration: POST /api/v1/ratings -- 인증된 사용자가 평점을 등록할 수 있다 -- 인증 없이 요청하면 401을 반환한다 -- 존재하지 않는 주류 ID로 요청하면 404를 반환한다 -``` - -Present the scenario list to the user and proceed to Phase 2 after approval. - -## Phase 2: Test Infrastructure (create if missing) - -### For Unit Tests - -Check and create if needed: -- `InMemory{Domain}Repository` in `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/` -- `{Domain}ObjectFixture` in the same fixture package - -### For Integration Tests - -Check and create if needed: - -**Product module:** -- `{Domain}TestFactory` in `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/` - -**Admin module:** -- `{Domain}Helper` (Kotlin object) in `bottlenote-admin-api/src/test/kotlin/app/helper/{domain}/` - -## Phase 3: Test Implementation - -Read `references/test-patterns.md` for code examples before writing tests. - -### Unit Test (`@Tag("unit")`) - -**Required for:** Service classes with business logic. - -| Item | Product (Java) | Admin | -|------|---------------|-------| -| Location | `product-api/.../app/bottlenote/{domain}/service/` | N/A (business logic is in mono) | -| Pattern | Fake/Stub (Mock is last resort, ask user first) | - | -| Naming | `Fake{Domain}ServiceTest` | - | - -Structure: -- `@BeforeEach`: wire SUT with InMemory repos + Fake facades -- `@Nested` + `@DisplayName`: group by method/scenario -- Given-When-Then in each test - -### Integration Test (`@Tag("integration")` or `@Tag("admin_integration")`) - -**Required for:** All API endpoints. - -| Item | Product (Java) | Admin (Kotlin) | -|------|---------------|----------------| -| Location | `product-api/.../app/bottlenote/{domain}/integration/` | `admin-api/.../app/integration/{domain}/` | -| Base class | `IntegrationTestSupport` | `IntegrationTestSupport` | -| Tag | `@Tag("integration")` | `@Tag("admin_integration")` | -| API client | `mockMvcTester` | `mockMvcTester` | -| Auth | `getToken()` / `getToken(user)` | `getAccessToken(admin)` | -| Data setup | `{Domain}TestFactory` (`@Autowired`) | `{Domain}TestFactory` (`@Autowired`) | - -Key patterns: -- `@Nested` per API endpoint -- Auth success + failure cases for each endpoint -- `extractData(result, ResponseType.class)` for response parsing -- `Awaitility` for async event verification - -### RestDocs Test (optional, user request only) - -| Item | Product (Java) | Admin (Kotlin) | -|------|---------------|----------------| -| Location | `product-api/.../app/docs/{domain}/` | `admin-api/.../app/docs/{domain}/` | -| Base class | `AbstractRestDocs` | `@WebMvcTest(excludeAutoConfiguration = [SecurityAutoConfiguration::class])` | -| Naming | `Rest{Domain}ControllerDocsTest` | `Admin{Domain}ControllerDocsTest` | -| Mocking | `@MockBean` services (acceptable here) | `@MockitoBean` services | - -## Phase 4: Verify - -After test implementation, run verification: - -| Scope | Command | -|-------|---------| -| Unit tests only | `/verify standard` (compile + unit + build) | -| With integration | `/verify full` (includes integration tests) | - -## Test Naming Convention - -- Class: `Fake{Feature}ServiceTest`, `{Feature}IntegrationTest`, `Rest{Domain}ControllerDocsTest` -- Method: `{action}_{scenario}_{expectedResult}` or Korean `@DisplayName` -- DisplayName: always in Korean, format `~할 때 ~한다`, `~하면 ~할 수 있다` - -## Important Rules - -- **Mock is last resort**: always prefer Fake/InMemory. Ask user before using Mockito. -- **RestDocs is optional**: only implement when user explicitly requests. -- **One test, one scenario**: each `@Test` verifies a single behavior. -- **Repository interface changes**: if you added methods to a domain repository, update the corresponding `InMemory{Domain}Repository` too. diff --git a/.claude/skills/implement-test/references/test-infra.md b/.claude/skills/implement-test/references/test-infra.md deleted file mode 100644 index 976146506..000000000 --- a/.claude/skills/implement-test/references/test-infra.md +++ /dev/null @@ -1,149 +0,0 @@ -# Test Infrastructure - -## Test Classification - -| Tag | Type | Base Class | Location | -|-----|------|------------|----------| -| `@Tag("unit")` | Unit test | None (plain JUnit) | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/service/` | -| `@Tag("integration")` | Integration test | `IntegrationTestSupport` | `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/integration/` | -| `@Tag("admin_integration")` | Admin integration | `IntegrationTestSupport` | `bottlenote-admin-api/src/test/kotlin/app/integration/{domain}/` | -| (none) | RestDocs test | `AbstractRestDocs` | `bottlenote-product-api/src/test/java/app/docs/{domain}/` | - -## Shared Test Utilities - -### IntegrationTestSupport - -Location: `bottlenote-product-api/src/test/java/app/bottlenote/IntegrationTestSupport.java` - -**Fields:** -- `ObjectMapper mapper` - JSON serialization -- `MockMvc mockMvc` - legacy Spring MVC test API -- `MockMvcTester mockMvcTester` - modern Spring 6+ fluent API (prefer this) -- `TestAuthenticationSupport authSupport` - token generation -- `DataInitializer dataInitializer` - DB cleanup - -**Helper methods:** -- `getToken()` - default user token (3 overloads: no-arg, User, userId) -- `getTokenUserId()` - get userId from default token -- `extractData(MvcTestResult, Class)` - parse GlobalResponse.data into target type -- `extractData(MvcResult, Class)` - legacy MockMvc version -- `parseResponse(MvcTestResult)` - parse raw response to GlobalResponse -- `parseResponse(MvcResult)` - legacy version - -**Auto cleanup:** `@AfterEach` calls `dataInitializer.deleteAll()` which TRUNCATEs all tables except system tables (databasechangelog, flyway, schema_version). - -### TestContainersConfig - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java` - -Containers (all with `@ServiceConnection` for auto-wiring): -- **MySQL 8.0.32** - `withReuse(true)`, DB name from `System.getProperty("testcontainers.db.name", "bottlenote")` -- **Redis 7.0.12** - `withReuse(true)` -- **MinIO** - S3-compatible storage with `AmazonS3` client bean - -Fake beans (`@Primary`, replaces real implementations in test context): -- `FakeWebhookRestTemplate` - captures HTTP calls instead of sending -- `FakeProfanityClient` - returns clean text without calling external API - -### TestAuthenticationSupport - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestAuthenticationSupport.java` - -- `getFirstUser()` - first user from DB -- `getAccessToken()` - default access token -- `getRandomAccessToken()` - random user token -- `createToken(OauthRequest)` / `createToken(User)` - custom token generation -- `getDefaultUserId()` / `getUserId(String email)` - user ID lookup - -### DataInitializer - -Location: `bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java` - -- `deleteAll()` - TRUNCATE all user tables (dynamic table discovery via `SHOW TABLES`) -- `refreshCache()` - refresh table list for dynamically created tables -- `@Transactional(REQUIRES_NEW)` for isolation -- Filters: `databasechangelog*`, `flyway_*`, `schema_version`, `BATCH_*`, `QRTZ_*` - -## Test Data Patterns - -### TestFactory (Integration tests) - -Location: `bottlenote-mono/src/test/java/app/bottlenote/{domain}/fixture/{Domain}TestFactory.java` - -TestFactory uses `EntityManager` + `@Transactional` (not JPA repositories). Method naming: `persist{Entity}()`. - -```java -@Component -public class RatingTestFactory { - @PersistenceContext - private EntityManager em; - - @Autowired - private AlcoholTestFactory alcoholTestFactory; - - private final AtomicInteger counter = new AtomicInteger(0); - - @Transactional - public Rating persistRating(Long alcoholId, Long userId) { - Rating rating = Rating.builder() - .alcoholId(alcoholId) - .userId(userId) - .ratingPoint(RatingPoint.FOUR) - .build(); - em.persist(rating); - em.flush(); - return rating; - } -} -``` - -Key patterns: -- `AtomicInteger counter` for unique suffixes (names, emails) -- `persistAndFlush()` variants for immediate ID access -- Compose other factories: `AlcoholTestFactory` uses `RegionTestFactory`, `DistilleryTestFactory` - -### ObjectFixture (Unit tests) - -Location: `bottlenote-product-api/src/test/java/app/bottlenote/{domain}/fixture/{Domain}ObjectFixture.java` - -Static factory methods returning pre-configured domain objects. No DB, no Spring. - -```java -public class RatingObjectFixture { - public static Rating createRating(Long alcoholId, Long userId) { - return Rating.builder() - .alcoholId(alcoholId) - .userId(userId) - .ratingPoint(RatingPoint.FOUR) - .build(); - } - - public static RatingRegisterRequest createRegisterRequest() { - return new RatingRegisterRequest(1L, 4.5); - } -} -``` - -Use ObjectFixture for unit tests, TestFactory for integration tests. - -## Existing Fake/InMemory Implementations - -Before creating a new Fake, check if one already exists. - -### InMemory Repositories -- `InMemoryRatingRepository`, `InMemoryReviewRepository`, `InMemoryLikesRepository` -- `InMemoryUserRepository`, `InMemoryUserQueryRepository` -- `InMemoryAlcoholQueryRepository`, `InMemoryFollowRepository` -- `InMemoryPicksRepository` (also `FakePicksRepository` in `picks/fake/`) -- Plus others for block, support, banner, etc. - -### Fake Services/Facades -- `FakeAlcoholFacade` - in-memory with `add()`, `remove()`, `clear()` -- `FakeUserFacade` - similar pattern -- `FakeHistoryEventPublisher` - captures published history events -- `FakeApplicationEventPublisher` - captures all Spring events (`getPublishedEvents()`, `getPublishedEventsOfType()`, `hasPublishedEventOfType()`, `clear()`) - -### Fake External Services -- `FakeWebhookRestTemplate` - captures HTTP calls (`getCallCount()`, `getLastRequestBody()`) -- `FakeProfanityClient` - returns input text as-is -- Fake JWT/BCrypt implementations for security testing diff --git a/.claude/skills/implement-test/references/test-patterns.md b/.claude/skills/implement-test/references/test-patterns.md deleted file mode 100644 index 539d1b67e..000000000 --- a/.claude/skills/implement-test/references/test-patterns.md +++ /dev/null @@ -1,230 +0,0 @@ -# Test Patterns - -## 1. Unit Test - Fake/Stub Pattern (Preferred) - -Use InMemory implementations instead of mocks. Closer to real behavior and resilient to refactoring. - -### Fake Repository - -```java -// Location: {domain}/fixture/InMemory{Domain}Repository.java -public class InMemoryRatingRepository implements RatingRepository { - private final Map database = new HashMap<>(); - private final AtomicLong idGenerator = new AtomicLong(1); - - @Override - public Rating save(Rating rating) { - if (rating.getId() == null) { - ReflectionTestUtils.setField(rating, "id", idGenerator.getAndIncrement()); - } - database.put(rating.getId(), rating); - return rating; - } - - @Override - public Optional findByAlcoholIdAndUserId(Long alcoholId, Long userId) { - return database.values().stream() - .filter(r -> r.getAlcoholId().equals(alcoholId) && r.getUserId().equals(userId)) - .findFirst(); - } -} -``` - -### Fake Test - -```java -// Location: {domain}/service/Fake{Domain}ServiceTest.java -@Tag("unit") -@DisplayName("{Domain} service unit test") -class FakeRatingCommandServiceTest { - - private RatingCommandService sut; // system under test - private InMemoryRatingRepository ratingRepository; - private FakeApplicationEventPublisher eventPublisher; - - @BeforeEach - void setUp() { - ratingRepository = new InMemoryRatingRepository(); - eventPublisher = new FakeApplicationEventPublisher(); - sut = new RatingCommandService(ratingRepository, eventPublisher, /* other fakes */); - } - - @Nested - @DisplayName("when registering a rating") - class RegisterRating { - - @Test - @DisplayName("valid request registers the rating") - void register_whenValidRequest_savesRating() { - // given - Long alcoholId = 1L; - Long userId = 1L; - RatingPoint point = RatingPoint.FIVE; - - // when - RatingRegisterResponse response = sut.register(alcoholId, userId, point); - - // then - assertThat(response).isNotNull(); - assertThat(ratingRepository.findByAlcoholIdAndUserId(alcoholId, userId)).isPresent(); - } - - @Test - @DisplayName("event is published") - void register_publishesEvent() { - sut.register(1L, 1L, RatingPoint.FIVE); - - assertThat(eventPublisher.getPublishedEvents()) - .hasSize(1) - .first() - .isInstanceOf(RatingRegistryEvent.class); - } - } -} -``` - -## 2. Unit Test - Mockito (Last Resort Only) - -Mockito couples tests to implementation details and breaks on refactoring. -**Always ask the user before choosing Mockito over Fake/Stub.** - -```java -@Tag("unit") -@ExtendWith(MockitoExtension.class) -class RatingCommandServiceTest { - - @InjectMocks - private RatingCommandService sut; - - @Mock - private RatingRepository ratingRepository; - - @Mock - private AlcoholFacade alcoholFacade; - - @Test - @DisplayName("non-existent alcohol throws exception") - void register_whenAlcoholNotFound_throwsException() { - // given - given(alcoholFacade.existsByAlcoholId(anyLong())).willReturn(false); - - // when & then - assertThatThrownBy(() -> sut.register(1L, 1L, RatingPoint.FIVE)) - .isInstanceOf(RatingException.class); - } -} -``` - -## 3. Integration Test - -Full Spring context with TestContainers (real DB). -Read `test-infra.md` for IntegrationTestSupport details. - -```java -// Location: {domain}/integration/{Domain}IntegrationTest.java -@Tag("integration") -@DisplayName("{Domain} integration test") -class RatingIntegrationTest extends IntegrationTestSupport { - - @Autowired - private RatingTestFactory ratingTestFactory; - - @Test - @DisplayName("register a rating") - void registerRating() { - // given - TestFactory for data setup - User user = userTestFactory.persistUser(); - Alcohol alcohol = alcoholTestFactory.persistAlcohol(); - String token = getToken(user); - - RatingRegisterRequest request = new RatingRegisterRequest(alcohol.getId(), 4.5); - - // when - MockMvcTester (modern API) - MvcTestResult result = mockMvcTester.post() - .uri("/api/v1/ratings") - .contentType(MediaType.APPLICATION_JSON) - .header("Authorization", "Bearer " + token) - .content(mapper.writeValueAsString(request)) - .exchange(); - - // then - helper methods - assertThat(result).hasStatusOk(); - GlobalResponse response = parseResponse(result); - assertThat(response.getSuccess()).isTrue(); - } -} -``` - -### Async Event Verification (Awaitility) - -```java -@Test -void register_triggersHistoryEvent() { - // ... perform action ... - - Awaitility.await() - .atMost(3, TimeUnit.SECONDS) - .untilAsserted(() -> { - List histories = historyRepository.findByUserId(userId); - assertThat(histories).hasSize(1); - }); -} -``` - -## 4. RestDocs Test - -API documentation with Spring REST Docs. Only implement when user explicitly requests. - -```java -// Location: app/docs/{domain}/Rest{Domain}ControllerDocsTest.java -class RestRatingControllerDocsTest extends AbstractRestDocs { - - @MockBean - private RatingCommandService commandService; - - @MockBean - private RatingQueryService queryService; - - @Override - protected Object initController() { - return new RatingController(commandService, queryService); - } - - @Test - @DisplayName("rating registration API docs") - void registerRating() throws Exception { - // given - given(commandService.register(anyLong(), anyLong(), any())) - .willReturn(new RatingRegisterResponse(1L)); - - mockSecurityContext(1L); // static mock for SecurityContextUtil - - // when & then - mockMvc.perform(post("/api/v1/ratings") - .contentType(MediaType.APPLICATION_JSON) - .content(mapper.writeValueAsString(request)) - .with(csrf())) - .andExpect(status().isOk()) - .andDo(document("rating-register", - requestFields( - fieldWithPath("alcoholId").type(NUMBER).description("alcohol ID"), - fieldWithPath("rating").type(NUMBER).description("rating value") - ), - responseFields( - fieldWithPath("success").type(BOOLEAN).description("success"), - fieldWithPath("code").type(NUMBER).description("status code"), - fieldWithPath("data").type(OBJECT).description("response data"), - fieldWithPath("data.ratingId").type(NUMBER).description("rating ID"), - fieldWithPath("errors").type(ARRAY).description("error list"), - fieldWithPath("meta").type(OBJECT).description("meta info"), - fieldWithPath("meta.serverVersion").type(STRING).description("server version"), - fieldWithPath("meta.serverEncoding").type(STRING).description("encoding"), - fieldWithPath("meta.serverResponseTime").type(STRING).description("response time"), - fieldWithPath("meta.serverPathVersion").type(STRING).description("API version") - ) - )); - } -} -``` - -Note: RestDocs tests use `AbstractRestDocs` which sets up standalone MockMvc with `MockMvcBuilders.standaloneSetup()`, pretty-print configuration, and `GlobalExceptionHandler`. These tests use `@MockBean` for services - this is the one place where mocking is acceptable because we're testing documentation, not business logic. From d9be84bd0952f148e57b32b8c8d43f3fce871776 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 21:01:14 +0900 Subject: [PATCH 26/31] deps: version sync --- git.environment-variables | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/git.environment-variables b/git.environment-variables index c31d51c60..57cd5c5bb 160000 --- a/git.environment-variables +++ b/git.environment-variables @@ -1 +1 @@ -Subproject commit c31d51c60a7fbad98fa0053d82af5b1fa0ee02da +Subproject commit 57cd5c5bbeb473be1c2b1036e6ea718a2f10227b From 2b80b7a60f02bf4a08e4adb409a68843d1dc0351 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 21:03:10 +0900 Subject: [PATCH 27/31] chore: update plan document to reflect implementation completion - Marked plan as complete with finalization date - Adjusted task prioritization to match actual implementation sequence - Documented removal of deprecated skills and restructuring of CLAUDE.md --- plan/claude-ai-harness-improvement.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md index 37c93e226..4b71b3ec6 100644 --- a/plan/claude-ai-harness-improvement.md +++ b/plan/claude-ai-harness-improvement.md @@ -2,7 +2,8 @@ > 작성일: 2026-04-08 > 참고: [agent-skills](https://github.com/addyosmani/agent-skills) (Addy Osmani) -> 상태: 설계 단계 +> 상태: 구현 완료 +> 완료일: 2026-04-08 --- @@ -553,18 +554,18 @@ sequenceDiagram --- -## 8. 구현 순서 (우선순위) +## 8. 구현 순서 (실제 진행 순서) -| 순서 | 작업 | 난이도 | 효과 | -|------|------|--------|------| -| **1** | 공통 뼈대 확정 (Rationalizations + Red Flags + Verification) | 낮음 | 높음 | -| **2** | `/implement` 스킬 작성 (implement-product-api + admin 통합) | 중간 | 높음 | -| **3** | `/test` 스킬 보강 (implement-test 리네이밍 + 구조 보강) | 낮음 | 중간 | -| **4** | `/debug` 스킬 작성 | 중간 | 높음 | -| **5** | `/self-review` 스킬 작성 | 중간 | 중간 | -| **6** | `/define` 스킬 작성 | 낮음 | 중간 | -| **7** | `/plan` 스킬 작성 | 낮음 | 중간 | -| **8** | CLAUDE.md 업데이트 (새 스킬 체계 반영) | 낮음 | 높음 | +| 순서 | 작업 | 커밋 | +|------|------|------| +| **1** | `/self-review` 스킬 작성 | `1db651a8` | +| **2** | `/implement` 스킬 작성 + references 3개 | `1db651a8` | +| **3** | `/test` 스킬 보강 + references 복사 | `1db651a8` | +| **4** | `/debug` 스킬 작성 | `1db651a8` | +| **5** | `/define` 스킬 작성 | `1db651a8` | +| **6** | `/plan` 스킬 작성 | `1db651a8` | +| **7** | CLAUDE.md 재구성 (프로젝트 개요 + Skills 섹션) | `1db651a8` | +| **8** | 기존 스킬 제거 (`implement-product-api`, `implement-test`) | `a2945e59` | --- @@ -572,6 +573,6 @@ sequenceDiagram - [x] `/implement`에서 batch 모듈은 **제외** (product + admin만) - [x] `/self-review`는 **커밋 전 self-review 전용** (PR 리뷰는 범위 밖) -- [x] 기존 스킬(`implement-product-api`, `implement-test`)은 **전체 완료 후 제거** (deprecated로 유지) +- [x] 기존 스킬(`implement-product-api`, `implement-test`)은 **제거 완료** (`a2945e59`) - [x] CLAUDE.md의 Admin/Product 구현 규칙은 `references/`로 **이동**, CLAUDE.md는 프로젝트 개요 중심으로 재구성 - [x] 스킬 문서(SKILL.md, references)는 **영어**로 작성. 한국어는 커밋 메시지, plan 문서, 대화 응답, @DisplayName 등으로 제한 From 05f7da8af4e69dbe88b76e79280ca6b090c2b8b0 Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 22:35:20 +0900 Subject: [PATCH 28/31] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminUserSortType, AdminUserSearchRequest, AdminUserListResponse DTO 추가 - CustomUserRepository QueryDSL 구현 (키워드 검색, 상태 필터, 활동 지표 서브쿼리, 정렬) - RatingQuerySupporter NumberPath 오버로드 추가 - AdminUserService, AdminUsersController(Kotlin) 생성 - InMemory 구현체 3개 stub 메서드 추가 - admin 통합 테스트 8개 작성 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../user/presentation/AdminUsersController.kt | 21 ++ .../user/AdminUsersIntegrationTest.kt | 223 ++++++++++++++++++ .../repository/RatingQuerySupporter.java | 8 + .../user/constant/AdminUserSortType.java | 29 +++ .../user/domain/UserRepository.java | 5 + .../dto/request/AdminUserSearchRequest.java | 31 +++ .../dto/response/AdminUserListResponse.java | 37 +++ .../user/repository/CustomUserRepository.java | 22 ++ .../repository/CustomUserRepositoryImpl.java | 121 ++++++++++ .../user/service/AdminUserService.java | 20 ++ .../operation/utils/DataInitializer.java | 9 +- .../operation/utils/TestContainersConfig.java | 5 +- .../fixture/InMemoryUserQueryRepository.java | 9 + .../fixture/InMemoryUserQueryRepository.java | 9 + .../user/fixture/InMemoryUserRepository.java | 9 + plan/admin-user-list-api.md | 134 +++++++++++ 16 files changed, 684 insertions(+), 8 deletions(-) create mode 100644 bottlenote-admin-api/src/main/kotlin/app/bottlenote/user/presentation/AdminUsersController.kt create mode 100644 bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminUserSortType.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminUserSearchRequest.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminUserListResponse.java create mode 100644 bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminUserService.java create mode 100644 plan/admin-user-list-api.md diff --git a/bottlenote-admin-api/src/main/kotlin/app/bottlenote/user/presentation/AdminUsersController.kt b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/user/presentation/AdminUsersController.kt new file mode 100644 index 000000000..dda791cc2 --- /dev/null +++ b/bottlenote-admin-api/src/main/kotlin/app/bottlenote/user/presentation/AdminUsersController.kt @@ -0,0 +1,21 @@ +package app.bottlenote.user.presentation + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.user.dto.request.AdminUserSearchRequest +import app.bottlenote.user.service.AdminUserService +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ModelAttribute +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/users") +class AdminUsersController( + private val adminUserService: AdminUserService +) { + @GetMapping + fun list( + @ModelAttribute request: AdminUserSearchRequest + ): ResponseEntity = ResponseEntity.ok(adminUserService.searchUsers(request)) +} diff --git a/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt b/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt new file mode 100644 index 000000000..107390eb1 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/integration/user/AdminUsersIntegrationTest.kt @@ -0,0 +1,223 @@ +package app.integration.user + +import app.IntegrationTestSupport +import app.bottlenote.alcohols.fixture.AlcoholTestFactory +import app.bottlenote.picks.fixture.PicksTestFactory +import app.bottlenote.rating.fixture.RatingTestFactory +import app.bottlenote.review.fixture.ReviewTestFactory +import app.bottlenote.user.fixture.UserTestFactory +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired + +@Tag("admin_integration") +@DisplayName("[integration] Admin Users API 통합 테스트") +class AdminUsersIntegrationTest : IntegrationTestSupport() { + + @Autowired + private lateinit var userTestFactory: UserTestFactory + + @Autowired + private lateinit var alcoholTestFactory: AlcoholTestFactory + + @Autowired + private lateinit var reviewTestFactory: ReviewTestFactory + + @Autowired + private lateinit var ratingTestFactory: RatingTestFactory + + @Autowired + private lateinit var picksTestFactory: PicksTestFactory + + private lateinit var accessToken: String + + @BeforeEach + fun setUp() { + val admin = adminUserTestFactory.persistRootAdmin() + accessToken = getAccessToken(admin) + } + + @Nested + @DisplayName("유저 목록 조회 API") + inner class ListUsers { + + @Test + @DisplayName("유저 목록을 조회할 수 있다") + fun listSuccess() { + // given + userTestFactory.persistUser() + userTestFactory.persistUser() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/users") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("키워드로 닉네임을 검색할 수 있다") + fun searchByNickname() { + // given + userTestFactory.persistUserWithNickname("검색대상유저") + userTestFactory.persistUser() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/users") + .param("keyword", "검색대상") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()") + .isEqualTo(1) + } + + @Test + @DisplayName("키워드로 이메일을 검색할 수 있다") + fun searchByEmail() { + // given + userTestFactory.persistUser("searchtarget", "이메일유저") + userTestFactory.persistUser() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/users") + .param("keyword", "searchtarget") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()") + .isEqualTo(1) + } + + @Test + @DisplayName("상태로 필터링할 수 있다") + fun filterByStatus() { + // given + userTestFactory.persistUser() + val deletedUser = userTestFactory.persistUser() + deletedUser.withdrawUser() + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/users") + .param("status", "ACTIVE") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.success") + .isEqualTo(true) + } + + @Test + @DisplayName("활동 지표(리뷰, 별점, 찜)가 포함된다") + fun includesActivityMetrics() { + // given + val user = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + + reviewTestFactory.persistReview(user, alcohol) + reviewTestFactory.persistReview(user, alcoholTestFactory.persistAlcohol()) + ratingTestFactory.persistRating(user, alcohol, 4) + picksTestFactory.persistPicks(alcohol.id, user.id) + + // when + val result = mockMvcTester + .get() + .uri("/users") + .param("keyword", user.nickName) + .header("Authorization", "Bearer $accessToken") + .exchange() + + // then + assertThat(result).hasStatusOk() + assertThat(result) + .bodyJson() + .extractingPath("$.data[0].reviewCount") + .isEqualTo(2) + assertThat(result) + .bodyJson() + .extractingPath("$.data[0].ratingCount") + .isEqualTo(1) + assertThat(result) + .bodyJson() + .extractingPath("$.data[0].picksCount") + .isEqualTo(1) + } + + @Test + @DisplayName("정렬 옵션이 동작한다") + fun sortByReviewCount() { + // given + userTestFactory.persistUser() // userA (리뷰 없음) + val userB = userTestFactory.persistUser() + val alcohol = alcoholTestFactory.persistAlcohol() + + reviewTestFactory.persistReview(userB, alcohol) + reviewTestFactory.persistReview(userB, alcoholTestFactory.persistAlcohol()) + + // when & then (리뷰 많은 순 DESC -> userB가 먼저) + val result = mockMvcTester + .get() + .uri("/users") + .param("sortType", "REVIEW_COUNT") + .param("sortOrder", "DESC") + .header("Authorization", "Bearer $accessToken") + .exchange() + + assertThat(result).hasStatusOk() + assertThat(result) + .bodyJson() + .extractingPath("$.data[0].userId") + .isEqualTo(userB.id.toInt()) + } + + @Test + @DisplayName("페이징이 동작한다") + fun pagination() { + // given + repeat(5) { userTestFactory.persistUser() } + + // when & then + assertThat( + mockMvcTester + .get() + .uri("/users") + .param("page", "0") + .param("size", "2") + .header("Authorization", "Bearer $accessToken") + ).hasStatusOk() + .bodyJson() + .extractingPath("$.data.length()") + .isEqualTo(2) + } + } + + @Nested + @DisplayName("인증 테스트") + inner class AuthenticationTest { + @Test + @DisplayName("인증 없이 요청하면 실패한다") + fun requestWithoutAuth() { + assertThat( + mockMvcTester.get().uri("/users") + ).hasStatus4xxClientError() + } + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java index 8862ad1ec..3442a3b66 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/rating/repository/RatingQuerySupporter.java @@ -70,6 +70,14 @@ public Expression ratingCountSubQuery(Long userId) { "ratingCount"); } + public Expression ratingCountSubQuery(NumberPath userId) { + return ExpressionUtils.as( + select(rating.count()) + .from(rating) + .where(rating.id.userId.eq(userId).and(rating.ratingPoint.rating.gt(0.0))), + "ratingCount"); + } + public Expression averageRatingSubQuery(NumberPath alocholId) { return ExpressionUtils.as( select(rating.ratingPoint.rating.avg().round()) diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminUserSortType.java b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminUserSortType.java new file mode 100644 index 000000000..d4cb621e4 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/constant/AdminUserSortType.java @@ -0,0 +1,29 @@ +package app.bottlenote.user.constant; + +import com.fasterxml.jackson.annotation.JsonCreator; +import java.util.stream.Stream; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum AdminUserSortType { + CREATED_AT("가입일"), + NICK_NAME("닉네임"), + EMAIL("이메일"), + RATING_COUNT("별점 많은 순"), + REVIEW_COUNT("리뷰 많은 순"); + + private final String description; + + @JsonCreator + public static AdminUserSortType parsing(String source) { + if (source == null || source.isEmpty()) { + return CREATED_AT; + } + return Stream.of(AdminUserSortType.values()) + .filter(sortType -> sortType.toString().equals(source.toUpperCase())) + .findFirst() + .orElse(CREATED_AT); + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/UserRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/UserRepository.java index 77573402c..1990bce9a 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/domain/UserRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/domain/UserRepository.java @@ -2,10 +2,13 @@ import app.bottlenote.global.service.cursor.PageResponse; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; /** 유저 정보에 관한 질의에 대한 애그리거트를 정의합니다. */ public interface UserRepository { @@ -27,4 +30,6 @@ public interface UserRepository { PageResponse getRatingMyBottle(MyBottlePageableCriteria criteria); PageResponse getPicksMyBottle(MyBottlePageableCriteria criteria); + + Page searchAdminUsers(AdminUserSearchRequest request); } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminUserSearchRequest.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminUserSearchRequest.java new file mode 100644 index 000000000..078bc4210 --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/request/AdminUserSearchRequest.java @@ -0,0 +1,31 @@ +package app.bottlenote.user.dto.request; + +import app.bottlenote.global.service.cursor.SortOrder; +import app.bottlenote.user.constant.AdminUserSortType; +import app.bottlenote.user.constant.UserStatus; +import lombok.Builder; + +/** + * @param keyword 검색 키워드 (닉네임/이메일) + * @param status 유저 상태 필터 (ACTIVE/DELETED) + * @param sortType 정렬 기준 + * @param sortOrder 정렬 방향 + * @param page 페이지 번호 (0부터) + * @param size 페이지 크기 + */ +public record AdminUserSearchRequest( + String keyword, + UserStatus status, + AdminUserSortType sortType, + SortOrder sortOrder, + Integer page, + Integer size) { + + @Builder + public AdminUserSearchRequest { + sortType = sortType != null ? sortType : AdminUserSortType.CREATED_AT; + sortOrder = sortOrder != null ? sortOrder : SortOrder.DESC; + page = page != null ? page : 0; + size = size != null ? size : 20; + } +} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminUserListResponse.java b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminUserListResponse.java new file mode 100644 index 000000000..ef78238bd --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/dto/response/AdminUserListResponse.java @@ -0,0 +1,37 @@ +package app.bottlenote.user.dto.response; + +import app.bottlenote.user.constant.SocialType; +import app.bottlenote.user.constant.UserStatus; +import app.bottlenote.user.constant.UserType; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 어드민 유저 목록 응답 항목 + * + * @param userId 유저 ID + * @param email 이메일 + * @param nickName 닉네임 + * @param imageUrl 프로필 이미지 + * @param role 유저 권한 + * @param status 유저 상태 + * @param socialType 소셜 로그인 타입 목록 + * @param reviewCount 리뷰 수 + * @param ratingCount 별점 수 + * @param picksCount 찜 수 + * @param createAt 가입일 + * @param lastLoginAt 최종 로그인일 + */ +public record AdminUserListResponse( + Long userId, + String email, + String nickName, + String imageUrl, + UserType role, + UserStatus status, + List socialType, + Long reviewCount, + Long ratingCount, + Long picksCount, + LocalDateTime createAt, + LocalDateTime lastLoginAt) {} diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepository.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepository.java index 44993319d..5d82d5d2d 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepository.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepository.java @@ -1,9 +1,15 @@ package app.bottlenote.user.repository; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.user.constant.UserStatus; +import app.bottlenote.user.constant.UserType; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; +import java.time.LocalDateTime; +import org.springframework.data.domain.Page; public interface CustomUserRepository { @@ -14,4 +20,20 @@ public interface CustomUserRepository { PageResponse getRatingMyBottle(MyBottlePageableCriteria criteria); PageResponse getPicksMyBottle(MyBottlePageableCriteria criteria); + + Page searchAdminUsers(AdminUserSearchRequest request); + + /** QueryDSL 프로젝션용 중간 레코드 (socialType은 JSON 컬럼이라 별도 처리) */ + record AdminUserRow( + Long userId, + String email, + String nickName, + String imageUrl, + UserType role, + UserStatus status, + Long reviewCount, + Long ratingCount, + Long picksCount, + LocalDateTime createAt, + LocalDateTime lastLoginAt) {} } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepositoryImpl.java b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepositoryImpl.java index 2260e93c8..c20f766fe 100644 --- a/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepositoryImpl.java +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/repository/CustomUserRepositoryImpl.java @@ -5,24 +5,36 @@ import static app.bottlenote.rating.domain.QRating.rating; import static app.bottlenote.review.domain.QReview.review; import static app.bottlenote.user.domain.QUser.user; +import static com.querydsl.jpa.JPAExpressions.select; import app.bottlenote.alcohols.repository.AlcoholQuerySupporter; import app.bottlenote.global.service.cursor.CursorPageable; import app.bottlenote.global.service.cursor.PageResponse; +import app.bottlenote.global.service.cursor.SortOrder; import app.bottlenote.picks.constant.PicksStatus; import app.bottlenote.picks.repository.PicksQuerySupporter; import app.bottlenote.rating.repository.RatingQuerySupporter; import app.bottlenote.review.constant.ReviewActiveStatus; import app.bottlenote.review.domain.QReviewTastingTag; import app.bottlenote.review.repository.ReviewQuerySupporter; +import app.bottlenote.user.constant.AdminUserSortType; import app.bottlenote.user.constant.MyBottleType; +import app.bottlenote.user.constant.SocialType; +import app.bottlenote.user.constant.UserStatus; +import app.bottlenote.user.domain.User; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; import app.bottlenote.user.dto.response.PicksMyBottleItem; import app.bottlenote.user.dto.response.RatingMyBottleItem; import app.bottlenote.user.dto.response.ReviewMyBottleItem; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.Collections; @@ -32,6 +44,9 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; @Slf4j @RequiredArgsConstructor @@ -327,4 +342,110 @@ public PageResponse getPicksMyBottle(MyBottlePageableCriteria MyBottleResponse.create(targetUserId, isMyPage, totalCount, picksMyBottleList); return PageResponse.of(myBottleResponse, cursorPageable); } + + @Override + public Page searchAdminUsers(AdminUserSearchRequest request) { + Expression reviewCountExpr = reviewQuerySupporter.reviewCountSubQuery(user.id); + Expression ratingCountExpr = ratingQuerySupporter.ratingCountSubQuery(user.id); + Expression picksCountExpr = pickQuerySupporter.picksCountSubQuery(user.id); + + List rows = + queryFactory + .select( + Projections.constructor( + AdminUserRow.class, + user.id, + user.email, + user.nickName, + user.imageUrl, + user.role, + user.status, + reviewCountExpr, + ratingCountExpr, + picksCountExpr, + user.createAt, + user.lastLoginAt)) + .from(user) + .where(adminUserKeyword(request.keyword()), adminUserStatus(request.status())) + .orderBy(adminUserSortOrder(request.sortType(), request.sortOrder())) + .offset((long) request.page() * request.size()) + .limit(request.size()) + .fetch(); + + // socialType 배치 로딩 + List userIds = rows.stream().map(AdminUserRow::userId).toList(); + Map> socialTypeMap = + userIds.isEmpty() + ? Map.of() + : queryFactory.selectFrom(user).where(user.id.in(userIds)).fetch().stream() + .collect(Collectors.toMap(User::getId, User::getSocialType)); + + List content = + rows.stream() + .map( + row -> + new AdminUserListResponse( + row.userId(), + row.email(), + row.nickName(), + row.imageUrl(), + row.role(), + row.status(), + socialTypeMap.getOrDefault(row.userId(), List.of()), + row.reviewCount(), + row.ratingCount(), + row.picksCount(), + row.createAt(), + row.lastLoginAt())) + .toList(); + + Long total = + queryFactory + .select(user.id.count()) + .from(user) + .where(adminUserKeyword(request.keyword()), adminUserStatus(request.status())) + .fetchOne(); + + return new PageImpl<>( + content, PageRequest.of(request.page(), request.size()), total != null ? total : 0L); + } + + private BooleanExpression adminUserKeyword(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return user.nickName.containsIgnoreCase(keyword).or(user.email.containsIgnoreCase(keyword)); + } + + private BooleanExpression adminUserStatus(UserStatus status) { + if (status == null) { + return null; + } + return user.status.eq(status); + } + + private OrderSpecifier adminUserSortOrder(AdminUserSortType sortType, SortOrder sortOrder) { + Order order = sortOrder == SortOrder.ASC ? Order.ASC : Order.DESC; + return switch (sortType) { + case CREATED_AT -> new OrderSpecifier<>(order, user.createAt); + case NICK_NAME -> new OrderSpecifier<>(order, user.nickName); + case EMAIL -> new OrderSpecifier<>(order, user.email); + case REVIEW_COUNT -> + new OrderSpecifier<>( + order, + select(review.count()) + .from(review) + .where( + review + .userId + .eq(user.id) + .and(review.activeStatus.eq(ReviewActiveStatus.ACTIVE)))); + case RATING_COUNT -> + new OrderSpecifier<>( + order, + select(rating.count()) + .from(rating) + .where(rating.id.userId.eq(user.id).and(rating.ratingPoint.rating.gt(0.0)))); + }; + } } diff --git a/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminUserService.java b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminUserService.java new file mode 100644 index 000000000..d95b8873a --- /dev/null +++ b/bottlenote-mono/src/main/java/app/bottlenote/user/service/AdminUserService.java @@ -0,0 +1,20 @@ +package app.bottlenote.user.service; + +import app.bottlenote.global.data.response.GlobalResponse; +import app.bottlenote.user.domain.UserRepository; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public GlobalResponse searchUsers(AdminUserSearchRequest request) { + return GlobalResponse.fromPage(userRepository.searchAdminUsers(request)); + } +} diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java index 919ce3ad2..9f6befd12 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/DataInitializer.java @@ -1,17 +1,16 @@ package app.bottlenote.operation.utils; +import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW; + import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import jakarta.persistence.Query; import jakarta.transaction.Transactional; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - import java.util.ArrayList; import java.util.List; import java.util.Set; - -import static jakarta.transaction.Transactional.TxType.REQUIRES_NEW; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; @Slf4j @Component diff --git a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java index 31de8da3b..47612571a 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/operation/utils/TestContainersConfig.java @@ -8,6 +8,8 @@ import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.AmazonS3ClientBuilder; import com.redis.testcontainers.RedisContainer; +import java.util.Collections; +import java.util.UUID; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.context.annotation.Bean; @@ -16,9 +18,6 @@ import org.testcontainers.containers.MySQLContainer; import org.testcontainers.utility.DockerImageName; -import java.util.Collections; -import java.util.UUID; - /** * TestContainers 설정을 관리하는 Spring Bean 기반 Configuration * diff --git a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java index 8309c5607..bd823be44 100644 --- a/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java +++ b/bottlenote-mono/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java @@ -4,12 +4,16 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.domain.UserRepository; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryUserQueryRepository implements UserRepository { @@ -63,4 +67,9 @@ public PageResponse getPicksMyBottle(MyBottlePageableCriteria public boolean existsByNickName(String nickname) { return users.values().stream().anyMatch(user -> user.getNickName().equals(nickname)); } + + @Override + public Page searchAdminUsers(AdminUserSearchRequest request) { + return new PageImpl<>(List.of()); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java index 8309c5607..bd823be44 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserQueryRepository.java @@ -4,12 +4,16 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.domain.UserRepository; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryUserQueryRepository implements UserRepository { @@ -63,4 +67,9 @@ public PageResponse getPicksMyBottle(MyBottlePageableCriteria public boolean existsByNickName(String nickname) { return users.values().stream().anyMatch(user -> user.getNickName().equals(nickname)); } + + @Override + public Page searchAdminUsers(AdminUserSearchRequest request) { + return new PageImpl<>(List.of()); + } } diff --git a/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java index 8b9292377..326f4253d 100644 --- a/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java +++ b/bottlenote-product-api/src/test/java/app/bottlenote/user/fixture/InMemoryUserRepository.java @@ -4,6 +4,8 @@ import app.bottlenote.user.domain.User; import app.bottlenote.user.domain.UserRepository; import app.bottlenote.user.dto.dsl.MyBottlePageableCriteria; +import app.bottlenote.user.dto.request.AdminUserSearchRequest; +import app.bottlenote.user.dto.response.AdminUserListResponse; import app.bottlenote.user.dto.response.MyBottleResponse; import app.bottlenote.user.dto.response.MyPageResponse; import java.util.HashMap; @@ -11,6 +13,8 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.test.util.ReflectionTestUtils; public class InMemoryUserRepository implements UserRepository { @@ -69,6 +73,11 @@ public PageResponse getPicksMyBottle(MyBottlePageableCriteria return null; } + @Override + public Page searchAdminUsers(AdminUserSearchRequest request) { + return new PageImpl<>(List.of()); + } + public void clear() { database.clear(); sequence = 1L; diff --git a/plan/admin-user-list-api.md b/plan/admin-user-list-api.md new file mode 100644 index 000000000..9a003cd86 --- /dev/null +++ b/plan/admin-user-list-api.md @@ -0,0 +1,134 @@ +# Plan: 어드민 - 유저 목록 조회 API + +## Overview + +어드민 페이지에서 앱 사용자(User) 목록을 조회하는 API를 구현한다. +유저 기본 정보와 함께 리뷰 수, 별점 수, 찜 수 등 활동 지표를 포함하여 반환한다. + +### Assumptions + +- 조회 대상은 일반 `User` (앱 사용자)이며 `AdminUser`가 아님 +- 어드민 JWT 인증 필요 +- 오프셋 기반 페이징 (기존 admin-api 패턴 동일) +- 활동 지표: 리뷰 수, 별점 수, 찜(Picks) 수 (팔로워/팔로잉 제외) +- 목록 조회만 구현 (상세 조회 제외) + +### Success Criteria + +- `GET /admin/api/v1/users` 엔드포인트가 유저 목록을 페이징하여 반환한다 +- 응답에 유저 기본 정보(id, email, nickName, imageUrl, role, status, socialType, 가입일, 최종 로그인일)가 포함된다 +- 응답에 활동 지표(reviewCount, ratingCount, picksCount)가 포함된다 +- 키워드 검색(닉네임/이메일)이 동작한다 +- 상태 필터(ACTIVE/DELETED)가 동작한다 +- 정렬 옵션: 가입일, 이름순, 이메일순, 별점 많은 순, 리뷰 많은 순 +- `GlobalResponse`로 래핑된 표준 응답 형식을 따른다 + +### Impact Scope + +- **mono 모듈**: UserRepository에 목록 조회 메서드 추가, DTO/QueryDSL 쿼리 추가 +- **admin-api 모듈**: Controller, Service 신규 생성 (Kotlin) +- **도메인**: user (주), review/rating/picks (활동 지표 집계 - 읽기 전용) +- **테스트**: unit, integration, RestDocs + +## Tasks + +### Task 1: mono - DTO, Constant 정의 +- 수용 기준: + - `AdminUserSortType` enum 생성 (CREATED_AT, NICK_NAME, EMAIL, RATING_COUNT, REVIEW_COUNT) + - `AdminUserSearchRequest` record 생성 (keyword, status, sortType, sortOrder, page, size) + - `AdminUserListResponse` record 생성 (userId, email, nickName, imageUrl, role, status, socialType, reviewCount, ratingCount, picksCount, createAt, lastLoginAt) +- 검증: `./gradlew :bottlenote-mono:compileJava` +- 파일: + - `mono/.../user/constant/AdminUserSortType.java` + - `mono/.../user/dto/request/AdminUserSearchRequest.java` + - `mono/.../user/dto/response/AdminUserListResponse.java` +- 크기: S +- 상태: [x] 완료 + +### Task 2: mono - QueryDSL 쿼리 구현 +- 수용 기준: + - `CustomUserRepository`에 `searchAdminUsers(AdminUserSearchRequest)` 메서드 추가 + - `CustomUserRepositoryImpl`에 QueryDSL 구현 (키워드 검색, 상태 필터, 정렬, 페이징) + - 기존 `reviewCountSubQuery`, `ratingCountSubQuery`, `picksCountSubQuery` 서브쿼리 재활용 + - `Page` 반환 (오프셋 기반) +- 검증: `./gradlew :bottlenote-mono:compileJava` +- 파일: + - `mono/.../user/repository/CustomUserRepository.java` (메서드 추가) + - `mono/.../user/repository/CustomUserRepositoryImpl.java` (구현 추가) +- 크기: M +- 상태: [x] 완료 + +### Checkpoint: Task 1-2 완료 후 +- [x] mono 모듈 컴파일 통과 +- [x] 아키텍처 규칙 통과 (`./gradlew check_rule_test`) + +### Task 3: mono - Service 구현 +- 수용 기준: + - `AdminUserService` 클래스 생성 + - `searchUsers(AdminUserSearchRequest)` 메서드 구현 + - `GlobalResponse` 래핑하여 반환 +- 검증: `./gradlew :bottlenote-mono:compileJava` +- 파일: + - `mono/.../user/service/AdminUserService.java` +- 크기: S +- 상태: [x] 완료 + +### Task 4: admin-api - Controller 구현 +- 수용 기준: + - `AdminUsersController.kt` 생성 + - `GET /users` 엔드포인트 (context-path `/admin/api/v1` 하위) + - `@ModelAttribute`로 검색 조건 바인딩 + - `AdminUserService` 위임 +- 검증: `./gradlew :bottlenote-admin-api:compileKotlin` +- 파일: + - `admin-api/.../user/presentation/AdminUsersController.kt` +- 크기: S +- 상태: [x] 완료 + +### Checkpoint: Task 3-4 완료 후 +- [x] 전체 빌드 통과 (`./gradlew build -x test`) +- [x] admin-api 모듈 컴파일 통과 + +## Progress Log + +- Task 1 완료: DTO, Constant 3개 파일 생성, 컴파일 통과 +- Task 2 완료: QueryDSL 구현, RatingQuerySupporter NumberPath 오버로드 추가 +- Task 3 완료: AdminUserService 생성, UserRepository 인터페이스 메서드 추가 +- Task 4 완료: AdminUsersController.kt 생성 +- Self-review: InMemory 구현체 3개 누락 발견 및 수정 +- Test: admin 통합 테스트 8개 작성 (목록 조회 7개 + 인증 1개) +- Verify L3: local record QueryDSL Projection 버그 수정 (인터페이스 레벨 record로 이동), 전체 통과 + +## Skill Cycle Summary (2026-04-08) + +| Skill | Result | +|-------|--------| +| `/define` | Plan 문서 생성, 가정 7개 확인, 성공 기준 7개 정의 | +| `/plan` | 4개 Task 분해 (S x 3, M x 1) | +| `/implement` | 9개 파일 생성/수정, Task 4개 완료 | +| `/self-review` | InMemory 구현체 누락 3건 발견 및 수정 (Critical) | +| `/test` | 통합 테스트 8개 작성 (목록 조회 7 + 인증 1), 전체 통과 | +| `/verify full` | L3 전체 통과 (local record Projection 버그 1건 수정) | + +### 변경 파일 목록 (12개) + +| File | Change | Module | +|------|--------|--------| +| `user/constant/AdminUserSortType.java` | 신규 | mono | +| `user/dto/request/AdminUserSearchRequest.java` | 신규 | mono | +| `user/dto/response/AdminUserListResponse.java` | 신규 | mono | +| `user/domain/UserRepository.java` | 메서드 추가 | mono | +| `user/repository/CustomUserRepository.java` | 메서드 + AdminUserRow record 추가 | mono | +| `user/repository/CustomUserRepositoryImpl.java` | QueryDSL 구현 추가 | mono | +| `rating/repository/RatingQuerySupporter.java` | NumberPath 오버로드 추가 | mono | +| `user/service/AdminUserService.java` | 신규 | mono | +| `user/presentation/AdminUsersController.kt` | 신규 | admin-api | +| `user/fixture/InMemoryUserQueryRepository.java` (mono) | stub 추가 | mono test | +| `user/fixture/InMemoryUserRepository.java` (product) | stub 추가 | product test | +| `user/fixture/InMemoryUserQueryRepository.java` (product) | stub 추가 | product test | +| `integration/user/AdminUsersIntegrationTest.kt` | 신규 | admin-api test | + +### Self-review/Verify에서 발견한 이슈 + +1. **InMemory 구현체 누락** (self-review) - `UserRepository` 인터페이스 변경 시 3개 InMemory 구현체 미갱신 -> 컴파일 에러 +2. **Local record QueryDSL Projection 실패** (verify L3) - 메서드 내 local record는 리플렉션에서 외부 클래스 참조 파라미터가 추가되어 `Projections.constructor()` 매칭 실패 -> 인터페이스 레벨 record로 이동하여 해결 From 82dd328fa5d9522512f647702704de37f2ff1d5a Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 22:40:11 +0900 Subject: [PATCH 29/31] =?UTF-8?q?chore:=20=EC=8A=A4=ED=82=AC=20=ED=92=80?= =?UTF-8?q?=20=EC=82=AC=EC=9D=B4=ED=81=B4=20=EA=B2=80=EC=A6=9D=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=8F=20plan=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-ai-harness-improvement.md에 동작 검증 및 피드백 섹션 추가 (P1~P5) - admin-user-list-api.md에 completion stamp 추가 후 plan/complete/로 이동 Co-Authored-By: Claude Opus 4.6 (1M context) --- plan/claude-ai-harness-improvement.md | 89 ++++++++++++++++++++++ plan/{ => complete}/admin-user-list-api.md | 22 ++++++ 2 files changed, 111 insertions(+) rename plan/{ => complete}/admin-user-list-api.md (87%) diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md index 4b71b3ec6..72114cae0 100644 --- a/plan/claude-ai-harness-improvement.md +++ b/plan/claude-ai-harness-improvement.md @@ -576,3 +576,92 @@ sequenceDiagram - [x] 기존 스킬(`implement-product-api`, `implement-test`)은 **제거 완료** (`a2945e59`) - [x] CLAUDE.md의 Admin/Product 구현 규칙은 `references/`로 **이동**, CLAUDE.md는 프로젝트 개요 중심으로 재구성 - [x] 스킬 문서(SKILL.md, references)는 **영어**로 작성. 한국어는 커밋 메시지, plan 문서, 대화 응답, @DisplayName 등으로 제한 + +--- + +## 10. 동작 검증 및 피드백 (2026-04-08) + +### 검증 대상 + +"어드민 유저 목록 조회 API" 기능을 `/define` -> `/plan` -> `/implement` -> `/self-review` -> `/test` -> `/verify full` 풀 사이클로 구현하며 스킬 체계를 실전 검증함. + +### 검증 결과 요약 + +| Skill | 동작 여부 | 비고 | +|-------|----------|------| +| `/define` | O | 가정 7개, 성공 기준 7개 도출. 사용자 승인 게이트 정상 작동 | +| `/plan` | O | 4개 Task 분해 (S x 3, M x 1). 의존 순서 정확 | +| `/implement` | O | 9개 파일 생성/수정, Slice 단위 컴파일 체크 정상 | +| `/self-review` | O | InMemory 구현체 누락 3건 발견 (Critical). 5축 리뷰 유효 | +| `/test` | O | 통합 테스트 8개 작성, 시나리오 정의 -> 구현 흐름 정상 | +| `/verify full` | O | L3 전체 통과. local record QueryDSL Projection 버그 발견 및 수정 | + +### 발견된 문제점 + +#### [P1] 스킬 간 자동 연결 부재 + +**현상**: `/implement` 완료 후 `/test`, `/verify full`로 자동 이어지지 않고, 사용자가 직접 "진행하자"라고 말해야 다음 스킬이 실행됨. + +**원인**: 각 스킬이 독립적으로 설계되어 있고, 다음 스킬을 자동 호출하라는 지시가 없음. CLAUDE.md에 라이프사이클 순서(`/define` -> `/plan` -> `/implement` -> `/test` -> `/verify full`)가 명시되어 있지만, 개별 스킬의 종료 시점에서 다음 스킬로의 전환을 안내하지 않음. + +**개선안**: +- `/implement` 스킬 마지막에 "모든 Task 완료 시 `/test` -> `/verify full`까지 연속 실행" 지시 추가 +- 또는 각 스킬 Verification 섹션에 "Next: `/xxx`" 안내 추가 + +#### [P2] plan 문서 마무리 프로세스 누락 + +**현상**: 풀 사이클 완료 후 stamp-template.st 기반 스탬프 추가 및 `plan/complete/` 이동이 수행되지 않음. + +**원인**: `/plan` 스킬의 Plan Document Lifecycle에 "전체 완료 -> 스탬프 추가 -> complete로 이동"이 정의되어 있지만, 이 마무리를 담당하는 스킬이 없음. `/implement`도 `/verify`도 이 책임을 갖고 있지 않음. + +**개선안**: +- `/verify full` 통과 후 또는 최종 커밋 후 stamp + complete 이동을 수행하는 마무리 단계를 `/implement` 또는 별도 스킬에 명시 +- 가장 간단한 방법: `/implement` 스킬의 Phase 4 (Final Verification) 이후에 plan 문서 마무리 절차 추가 + +#### [P3] API 문서화 스킬 부재 + +**현상**: 현재 스킬 체계에 RestDocs 테스트 작성 후 API 스펙 문서(adoc)를 생성하는 스킬이 없음. + +**원인**: `/test` 스킬에서 RestDocs 테스트 작성은 "사용자 요청 시만" 옵션으로 존재하지만, RestDocs 테스트 후 각 모듈의 `docs/` 디렉토리에 adoc 스펙 문서를 추가하는 워크플로우가 정의되어 있지 않음. + +**기대 흐름**: `/implement` -> `/test` (RestDocs 포함) -> adoc 스펙 생성 -> `./gradlew asciidoctor` 검증 + +**개선안**: +- `/test` 스킬에 RestDocs 작성 시 adoc 스펙 문서 생성까지 포함하는 Phase 추가 +- 또는 별도 `/docs` 스킬 신설: RestDocs 테스트 기반 API 문서화 전담 +- 문서 생성 후 `./gradlew asciidoctor` 검증을 `/verify` L2 이상에 포함 + +#### [P4] self-review에서 발견한 패턴: InMemory 구현체 갱신 누락 + +**현상**: `UserRepository` 인터페이스에 메서드 추가 시 3개의 InMemory 구현체(mono 1개, product-api 2개) 갱신을 누락하여 컴파일 에러 발생. + +**원인**: `/implement` 스킬에 "Repository 인터페이스 변경 시 InMemory 구현체도 갱신하라"는 Red Flag은 있지만, 구체적으로 어떤 모듈의 어떤 파일을 확인해야 하는지 가이드가 부족함. + +**개선안**: `/implement` 스킬의 Phase 1 (Mono Module) 또는 Red Flags에 구체적 체크 항목 추가: +``` +Repository 인터페이스 변경 시 확인 대상: +- mono/src/test/.../fixture/InMemory{Domain}*.java +- product-api/src/test/.../fixture/InMemory{Domain}*.java +``` + +#### [P5] verify L3에서 발견한 패턴: local record와 QueryDSL Projection 비호환 + +**현상**: 메서드 내부 local record를 `Projections.constructor()`에 사용하면 런타임에 생성자를 찾지 못함. 컴파일은 통과하지만 실행 시 실패. + +**원인**: local record는 컴파일 시 외부 클래스 참조 파라미터가 숨겨져 추가되어, QueryDSL 리플렉션 기반 생성자 매칭이 실패함. + +**개선안**: `/implement` 스킬의 references 또는 Red Flags에 추가: +``` +QueryDSL Projections.constructor()에 사용하는 record는 반드시 +클래스/인터페이스 레벨에 정의할 것. 메서드 내 local record는 리플렉션 실패. +``` + +### 우선순위 + +| 우선순위 | 항목 | 난이도 | +|---------|------|--------| +| 높음 | P1: 스킬 간 자동 연결 | 각 스킬 SKILL.md에 Next 안내 추가 (S) | +| 높음 | P2: plan 문서 마무리 프로세스 | `/implement` Phase 4에 절차 추가 (S) | +| 중간 | P3: API 문서화 스킬 | `/docs` 스킬 신설 또는 `/test` 확장 (M) | +| 낮음 | P4: InMemory 갱신 가이드 강화 | Red Flags에 구체적 경로 추가 (S) | +| 낮음 | P5: local record 주의사항 | references에 한 줄 추가 (S) | diff --git a/plan/admin-user-list-api.md b/plan/complete/admin-user-list-api.md similarity index 87% rename from plan/admin-user-list-api.md rename to plan/complete/admin-user-list-api.md index 9a003cd86..156c9dce8 100644 --- a/plan/admin-user-list-api.md +++ b/plan/complete/admin-user-list-api.md @@ -1,3 +1,25 @@ +``` +================================================================================ + PROJECT COMPLETION STAMP +================================================================================ +Status: **COMPLETED** +Completion Date: 2026-04-08 + +** Core Achievements ** +- GET /admin/api/v1/users 엔드포인트 구현 (키워드 검색, 상태 필터, 5종 정렬, 페이징) +- 활동 지표(reviewCount, ratingCount, picksCount) 서브쿼리 기반 집계 +- admin 통합 테스트 8개 작성, L3 전체 통과 + +** Key Components ** +- AdminUsersController.kt: admin-api 컨트롤러 (Kotlin) +- CustomUserRepositoryImpl.searchAdminUsers(): QueryDSL 쿼리 (socialType 배치 로딩) +- AdminUserService.java: 서비스 계층 (GlobalResponse.fromPage 위임) + +** Deferred Items ** +- RestDocs API 문서화: /docs 스킬 부재로 미작성 +================================================================================ +``` + # Plan: 어드민 - 유저 목록 조회 API ## Overview From f46c17f35b8e73fdabe4ddc6b35f115c392cae8f Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 22:46:41 +0900 Subject: [PATCH 30/31] =?UTF-8?q?docs:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20RestDocs=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminUsersControllerDocsTest.kt RestDocs 테스트 작성 - admin-users/users.adoc 스펙 문서 생성 - admin-api.adoc에 Users API 섹션 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/docs/asciidoc/admin-api.adoc | 6 + .../docs/asciidoc/api/admin-users/users.adoc | 25 ++++ .../docs/user/AdminUsersControllerDocsTest.kt | 117 ++++++++++++++++++ 3 files changed, 148 insertions(+) create mode 100644 bottlenote-admin-api/src/docs/asciidoc/api/admin-users/users.adoc create mode 100644 bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt diff --git a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc index 246125c82..53cef55a7 100644 --- a/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc +++ b/bottlenote-admin-api/src/docs/asciidoc/admin-api.adoc @@ -70,6 +70,12 @@ include::api/admin-curations/curations.adoc[] ''' +== Users API + +include::api/admin-users/users.adoc[] + +''' + == Banner API include::api/admin-banners/banners.adoc[] diff --git a/bottlenote-admin-api/src/docs/asciidoc/api/admin-users/users.adoc b/bottlenote-admin-api/src/docs/asciidoc/api/admin-users/users.adoc new file mode 100644 index 000000000..d35522993 --- /dev/null +++ b/bottlenote-admin-api/src/docs/asciidoc/api/admin-users/users.adoc @@ -0,0 +1,25 @@ +=== 유저 목록 조회 === + +- 앱 사용자(User) 목록을 페이지네이션으로 조회합니다. +- 키워드(닉네임/이메일), 상태(ACTIVE/DELETED)로 필터링이 가능합니다. +- 활동 지표(리뷰 수, 별점 수, 찜 수)가 포함됩니다. + +[source] +---- +GET /admin/api/v1/users +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/admin/users/list/query-parameters.adoc[] +include::{snippets}/admin/users/list/curl-request.adoc[] +include::{snippets}/admin/users/list/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/admin/users/list/response-fields.adoc[] +include::{snippets}/admin/users/list/http-response.adoc[] diff --git a/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt b/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt new file mode 100644 index 000000000..abd0d3ef0 --- /dev/null +++ b/bottlenote-admin-api/src/test/kotlin/app/docs/user/AdminUsersControllerDocsTest.kt @@ -0,0 +1,117 @@ +package app.docs.user + +import app.bottlenote.global.data.response.GlobalResponse +import app.bottlenote.user.constant.SocialType +import app.bottlenote.user.constant.UserStatus +import app.bottlenote.user.constant.UserType +import app.bottlenote.user.dto.request.AdminUserSearchRequest +import app.bottlenote.user.dto.response.AdminUserListResponse +import app.bottlenote.user.presentation.AdminUsersController +import app.bottlenote.user.service.AdminUserService +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.mockito.ArgumentMatchers.any +import org.mockito.BDDMockito.given +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.data.domain.PageImpl +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document +import org.springframework.restdocs.operation.preprocess.Preprocessors.* +import org.springframework.restdocs.payload.JsonFieldType +import org.springframework.restdocs.payload.PayloadDocumentation.* +import org.springframework.restdocs.request.RequestDocumentation.* +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.web.servlet.assertj.MockMvcTester +import java.time.LocalDateTime + +@WebMvcTest( + controllers = [AdminUsersController::class], + excludeAutoConfiguration = [SecurityAutoConfiguration::class] +) +@AutoConfigureRestDocs +@DisplayName("Admin Users 컨트롤러 RestDocs 테스트") +class AdminUsersControllerDocsTest { + + @Autowired + private lateinit var mvc: MockMvcTester + + @MockitoBean + private lateinit var adminUserService: AdminUserService + + @Test + @DisplayName("유저 목록을 조회할 수 있다") + fun listUsers() { + // given + val items = listOf( + AdminUserListResponse( + 1L, "user1@example.com", "사용자1", "https://img.example.com/1.jpg", + UserType.ROLE_USER, UserStatus.ACTIVE, listOf(SocialType.KAKAO), + 5L, 3L, 2L, + LocalDateTime.of(2025, 1, 15, 10, 0), LocalDateTime.of(2026, 4, 1, 14, 30) + ), + AdminUserListResponse( + 2L, "user2@example.com", "사용자2", null, + UserType.ROLE_USER, UserStatus.ACTIVE, listOf(SocialType.GOOGLE, SocialType.APPLE), + 0L, 1L, 0L, + LocalDateTime.of(2025, 6, 20, 9, 0), null + ) + ) + val page = PageImpl(items) + val response = GlobalResponse.fromPage(page) + + given(adminUserService.searchUsers(any(AdminUserSearchRequest::class.java))) + .willReturn(response) + + // when & then + assertThat( + mvc.get().uri("/users?keyword=&status=ACTIVE&sortType=CREATED_AT&sortOrder=DESC&page=0&size=20") + ) + .hasStatusOk() + .apply( + document( + "admin/users/list", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + queryParameters( + parameterWithName("keyword").description("검색 키워드 (닉네임/이메일)").optional(), + parameterWithName("status").description("유저 상태 필터 (ACTIVE/DELETED)").optional(), + parameterWithName("sortType").description("정렬 기준 (CREATED_AT/NICK_NAME/EMAIL/RATING_COUNT/REVIEW_COUNT, 기본값: CREATED_AT)").optional(), + parameterWithName("sortOrder").description("정렬 방향 (ASC/DESC, 기본값: DESC)").optional(), + parameterWithName("page").description("페이지 번호 (0부터 시작, 기본값: 0)").optional(), + parameterWithName("size").description("페이지 크기 (기본값: 20)").optional() + ), + responseFields( + fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"), + fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"), + fieldWithPath("data[]").type(JsonFieldType.ARRAY).description("유저 목록"), + fieldWithPath("data[].userId").type(JsonFieldType.NUMBER).description("유저 ID"), + fieldWithPath("data[].email").type(JsonFieldType.STRING).description("이메일"), + fieldWithPath("data[].nickName").type(JsonFieldType.STRING).description("닉네임"), + fieldWithPath("data[].imageUrl").type(JsonFieldType.VARIES).description("프로필 이미지 URL").optional(), + fieldWithPath("data[].role").type(JsonFieldType.STRING).description("유저 권한 (ROLE_USER/ROLE_ADMIN)"), + fieldWithPath("data[].status").type(JsonFieldType.STRING).description("유저 상태 (ACTIVE/DELETED)"), + fieldWithPath("data[].socialType").type(JsonFieldType.ARRAY).description("소셜 로그인 타입 목록 (KAKAO/NAVER/GOOGLE/APPLE)"), + fieldWithPath("data[].reviewCount").type(JsonFieldType.NUMBER).description("리뷰 수"), + fieldWithPath("data[].ratingCount").type(JsonFieldType.NUMBER).description("별점 수"), + fieldWithPath("data[].picksCount").type(JsonFieldType.NUMBER).description("찜 수"), + fieldWithPath("data[].createAt").type(JsonFieldType.STRING).description("가입일"), + fieldWithPath("data[].lastLoginAt").type(JsonFieldType.VARIES).description("최종 로그인일").optional(), + fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"), + fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"), + fieldWithPath("meta.page").type(JsonFieldType.NUMBER).description("현재 페이지 번호"), + fieldWithPath("meta.size").type(JsonFieldType.NUMBER).description("페이지 크기"), + fieldWithPath("meta.totalElements").type(JsonFieldType.NUMBER).description("전체 요소 수"), + fieldWithPath("meta.totalPages").type(JsonFieldType.NUMBER).description("전체 페이지 수"), + fieldWithPath("meta.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"), + fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).ignored(), + fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).ignored() + ) + ) + ) + } +} From 6c260fb6fddf5a46f4c520a9da0d310f05fbbeec Mon Sep 17 00:00:00 2001 From: rlagu Date: Wed, 8 Apr 2026 22:48:53 +0900 Subject: [PATCH 31/31] =?UTF-8?q?docs:=20/docs=20=EC=8A=A4=ED=82=AC=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=EC=95=88=20=EC=B6=94=EA=B0=80=20(API=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=ED=99=94=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 0~4 흐름 정의 (탐색 -> RestDocs 테스트 -> adoc 작성 -> 인덱스 등록 -> asciidoctor 검증) - product-api/admin-api 모듈별 패턴 분기 명시 - adoc 템플릿 구조 및 스니펫 include 규칙 정리 Co-Authored-By: Claude Opus 4.6 (1M context) --- plan/claude-ai-harness-improvement.md | 191 ++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) diff --git a/plan/claude-ai-harness-improvement.md b/plan/claude-ai-harness-improvement.md index 72114cae0..f60dbe873 100644 --- a/plan/claude-ai-harness-improvement.md +++ b/plan/claude-ai-harness-improvement.md @@ -665,3 +665,194 @@ QueryDSL Projections.constructor()에 사용하는 record는 반드시 | 중간 | P3: API 문서화 스킬 | `/docs` 스킬 신설 또는 `/test` 확장 (M) | | 낮음 | P4: InMemory 갱신 가이드 강화 | Red Flags에 구체적 경로 추가 (S) | | 낮음 | P5: local record 주의사항 | references에 한 줄 추가 (S) | + +--- + +## 11. `/docs` 스킬 설계 (API 문서화) + +### 배경 + +현재 스킬 체계에서 RestDocs 테스트 작성과 adoc 스펙 문서 생성을 담당하는 스킬이 없다. +`/test` 스킬에서 RestDocs는 "사용자 요청 시만" 옵션으로 존재하지만, 스니펫 생성 후 adoc 파일 작성 -> 인덱스 등록 -> asciidoctor 빌드 검증까지의 워크플로우가 정의되어 있지 않다. + +### 위치 + +라이프사이클에서 `/test` 이후, `/verify` 이전에 위치한다. + +``` +/define -> /plan -> /implement (+ /self-review) -> /test -> /docs -> /verify full +``` + +### 트리거 + +- "문서 작성해줘", "API 문서화", "RestDocs 추가", "adoc 작성" +- `/test` 완료 후 자동 연결 (사용자가 문서화를 요청한 경우) + +### 전체 흐름 + +```mermaid +flowchart TD + A["Phase 0: Explore
기존 문서 구조 탐색"] --> B["Phase 1: RestDocs 테스트 작성
스니펫 생성"] + B --> C["Phase 2: adoc 스펙 문서 작성"] + C --> D["Phase 3: 인덱스 등록"] + D --> E["Phase 4: asciidoctor 빌드 검증"] +``` + +### Phase 0: Explore + +기존 문서 구조를 탐색하여 컨벤션을 파악한다. + +``` +확인 대상: +├── 대상 모듈 확인 (product-api / admin-api) +├── 기존 RestDocs 테스트 패턴 +│ ├── product: AbstractRestDocs 상속, Java, mockMvc.perform() +│ └── admin: @WebMvcTest + @AutoConfigureRestDocs, Kotlin, MockMvcTester.apply() +├── 기존 adoc 디렉토리 구조 +│ ├── product: src/docs/asciidoc/api/{domain}/{feature}.adoc +│ └── admin: src/docs/asciidoc/api/admin-{domain}/{feature}.adoc +└── 인덱스 파일 위치 + ├── product: product-api.adoc + └── admin: admin-api.adoc +``` + +### Phase 1: RestDocs 테스트 작성 + +모듈별로 다른 패턴을 따른다. + +**product-api (Java)**: +``` +위치: product-api/src/test/java/app/docs/{domain}/RestDocs{Domain}ControllerTest.java +베이스: AbstractRestDocs 상속 +서비스: mock(XxxService.class) 필드 선언 +인증: mockStatic(SecurityContextUtil.class) +문서 ID: "{domain}/{operation}" (예: "rating/register") +``` + +**admin-api (Kotlin)**: +``` +위치: admin-api/src/test/kotlin/app/docs/{domain}/Admin{Domain}ControllerDocsTest.kt +어노테이션: @WebMvcTest + @AutoConfigureRestDocs +서비스: @MockitoBean +인증: excludeAutoConfiguration = [SecurityAutoConfiguration::class] +문서 ID: "admin/{domain}/{operation}" (예: "admin/users/list") +``` + +**공통 규칙**: +- 각 엔드포인트별 1개 테스트 메서드 +- queryParameters / pathParameters / requestFields / responseFields 모두 문서화 +- meta 필드(serverVersion 등)는 `.ignored()` 처리 +- `preprocessRequest(prettyPrint())`, `preprocessResponse(prettyPrint())` 필수 + +**스니펫 생성 확인**: +```bash +# 테스트 실행 후 스니펫 디렉토리 확인 +ls build/generated-snippets/admin/{domain}/{operation}/ +# 예상 파일: curl-request.adoc, http-request.adoc, http-response.adoc, +# query-parameters.adoc, response-fields.adoc 등 +``` + +### Phase 2: adoc 스펙 문서 작성 + +스니펫을 include하는 adoc 파일을 생성한다. + +**위치**: +- product: `src/docs/asciidoc/api/{domain}/{feature}.adoc` +- admin: `src/docs/asciidoc/api/admin-{domain}/{feature}.adoc` + +**구조 템플릿**: +```asciidoc +=== {API 제목} === + +- {API 설명} +- {필터/검색 조건 안내} + +[source] +---- +{HTTP_METHOD} {context-path}/{endpoint} +---- + +[discrete] +==== 요청 파라미터 ==== + +[discrete] +include::{snippets}/{document-id}/query-parameters.adoc[] +include::{snippets}/{document-id}/curl-request.adoc[] +include::{snippets}/{document-id}/http-request.adoc[] + +[discrete] +==== 응답 파라미터 ==== + +[discrete] +include::{snippets}/{document-id}/response-fields.adoc[] +include::{snippets}/{document-id}/http-response.adoc[] +``` + +**엔드포인트별 스니펫 include 분기**: +- GET (목록): query-parameters + response-fields +- GET (상세): path-parameters + response-fields +- POST/PUT: request-fields + response-fields (+ path-parameters if applicable) +- DELETE: path-parameters + response-fields + +### Phase 3: 인덱스 등록 + +메인 인덱스 adoc 파일에 새 섹션을 추가한다. + +```asciidoc +== {Domain} API + +include::api/{admin-}{domain}/{feature}.adoc[] + +''' +``` + +**배치 순서**: 기존 섹션들의 알파벳/논리 순서에 맞춰 삽입한다. + +### Phase 4: asciidoctor 빌드 검증 + +```bash +# admin-api +./gradlew :bottlenote-admin-api:asciidoctor + +# product-api +./gradlew :bottlenote-product-api:asciidoctor +``` + +**검증 기준**: +- BUILD SUCCESSFUL +- `build/asciidoc/html5/` 아래 HTML 파일 생성 확인 +- 새로 추가한 섹션이 HTML에 포함되어 있는지 확인 + +### 인자 + +``` +/docs [domain] [product|admin] +``` + +**예시**: +- `/docs user admin` - admin-api 유저 API 문서화 +- `/docs rating product` - product-api 평점 API 문서화 + +### Rationalizations + +| 핑계 | 현실 | +|------|------| +| "문서는 나중에 쓸게" | API가 배포되면 문서 없이 프론트엔드가 개발할 수 없다. 구현과 함께 작성해야 한다. | +| "Swagger로 충분하다" | RestDocs는 테스트 기반이라 실제 동작하는 API만 문서화된다. Swagger는 어노테이션 기반이라 코드와 괴리가 생긴다. | +| "adoc 파일은 그냥 복사하면 된다" | 스니펫 경로, 문서 ID, include 구조가 모듈/엔드포인트마다 다르다. 패턴을 따라야 빌드가 통과한다. | + +### Red Flags + +- RestDocs 테스트 없이 adoc 파일만 작성 (스니펫이 없으면 빌드 실패) +- 문서 ID와 adoc include 경로 불일치 +- 인덱스 파일에 include 누락 (HTML에 섹션이 빠짐) +- `asciidoctor` 빌드 검증 생략 +- product/admin 패턴 혼용 (product에서 @WebMvcTest 사용, admin에서 AbstractRestDocs 사용) + +### Verification + +- [ ] RestDocs 테스트가 통과하고 스니펫이 생성됨 +- [ ] adoc 파일이 올바른 위치에 생성됨 +- [ ] 인덱스 adoc에 새 섹션이 등록됨 +- [ ] `./gradlew asciidoctor` 빌드 성공 +- [ ] 생성된 HTML에 새 API 섹션이 포함됨