From cdff31422c2f4facc97a494149e0c0ca0c56f906 Mon Sep 17 00:00:00 2001 From: Klaus Date: Mon, 1 Jun 2026 10:19:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(home):=20=EC=B6=94=EC=B2=9C=20=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=97=90=EC=9D=B4=ED=84=B0=20=EB=8F=99=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=94=EB=A1=9C=EC=9A=B0=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/HomeRecommendationController.kt | 40 ++++ .../dto/FollowRecommendedCreatorsRequest.kt | 5 + .../home/HomeRecommendationControllerTest.kt | 214 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt create mode 100644 src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt new file mode 100644 index 00000000..bd741a9d --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/adapter/in/web/HomeRecommendationController.kt @@ -0,0 +1,40 @@ +package kr.co.vividnext.sodalive.v2.api.home.adapter.`in`.web + +import kr.co.vividnext.sodalive.common.ApiResponse +import kr.co.vividnext.sodalive.common.SodaException +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.v2.api.home.dto.FollowRecommendedCreatorsRequest +import kr.co.vividnext.sodalive.v2.recommend.application.RecommendedCreatorFollowService +import org.springframework.security.core.annotation.AuthenticationPrincipal +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 + +@RestController +@RequestMapping("/api/v2/home/recommendations") +class HomeRecommendationController( + private val recommendedCreatorFollowService: RecommendedCreatorFollowService +) { + @PostMapping("/creators/follow") + fun followRecommendedCreators( + @RequestBody request: FollowRecommendedCreatorsRequest, + @AuthenticationPrincipal(expression = "#this == 'anonymousUser' ? null : member") member: Member? + ) = run { + if (member == null) throw SodaException(messageKey = "common.error.bad_credentials") + val creatorIds = request.creatorIds + if (creatorIds.isNullOrEmpty() || creatorIds.size > MAX_CREATOR_IDS) { + throw SodaException(messageKey = "common.error.invalid_request") + } + + recommendedCreatorFollowService.followCreators( + member = member, + creatorIds = creatorIds + ) + ApiResponse.ok() + } + + companion object { + private const val MAX_CREATOR_IDS = 50 + } +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt new file mode 100644 index 00000000..d0c75365 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/v2/api/home/dto/FollowRecommendedCreatorsRequest.kt @@ -0,0 +1,5 @@ +package kr.co.vividnext.sodalive.v2.api.home.dto + +data class FollowRecommendedCreatorsRequest( + val creatorIds: List? +) diff --git a/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt new file mode 100644 index 00000000..b9b258ff --- /dev/null +++ b/src/test/kotlin/kr/co/vividnext/sodalive/v2/api/home/HomeRecommendationControllerTest.kt @@ -0,0 +1,214 @@ +package kr.co.vividnext.sodalive.v2.api.home + +import kr.co.vividnext.sodalive.member.Member +import kr.co.vividnext.sodalive.member.MemberAdapter +import kr.co.vividnext.sodalive.member.MemberRepository +import kr.co.vividnext.sodalive.member.MemberRole +import kr.co.vividnext.sodalive.member.following.CreatorFollowing +import kr.co.vividnext.sodalive.member.following.CreatorFollowingRepository +import kr.co.vividnext.sodalive.support.EmbeddedRedisInitializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional +import javax.persistence.EntityManager + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +@ContextConfiguration(initializers = [EmbeddedRedisInitializer::class]) +class HomeRecommendationControllerTest @Autowired constructor( + private val mockMvc: MockMvc, + private val memberRepository: MemberRepository, + private val creatorFollowingRepository: CreatorFollowingRepository, + private val entityManager: EntityManager +) { + @Test + @DisplayName("추천 크리에이터 동시 팔로우 비로그인 요청은 Spring Security에서 거부한다") + fun shouldRejectAnonymousFollowRequest() { + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[1,2]}""") + ) + .andExpect(status().isUnauthorized) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 성공 응답은 id 목록 없이 성공 여부만 반환하고 신규 팔로우만 저장한다") + fun shouldReturnSuccessOnlyAndPersistOnlyNewFollows() { + val member = saveMember("viewer", MemberRole.USER) + val newCreator = saveMember("new-creator", MemberRole.CREATOR) + val followedCreator = saveMember("followed-creator", MemberRole.CREATOR) + saveFollowing(member = member, creator = followedCreator) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[${newCreator.id},${followedCreator.id},${member.id}]}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").doesNotExist()) + + entityManager.flush() + entityManager.clear() + assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(newCreator.id!!, member.id!!)) + assertNotNull(creatorFollowingRepository.findByCreatorIdAndMemberId(followedCreator.id!!, member.id!!)) + assertEquals(2, creatorFollowingRepository.findAll().size) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우는 비활성 팔로우 이력을 신규 row 없이 다시 활성화한다") + fun shouldReactivateInactiveFollowingThroughApi() { + val member = saveMember("viewer", MemberRole.USER) + val creator = saveMember("reactivate-creator", MemberRole.CREATOR) + val inactiveFollowing = saveFollowing(member = member, creator = creator).apply { + isNotify = false + isActive = false + } + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[${creator.id}]}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").doesNotExist()) + + entityManager.flush() + entityManager.clear() + val reactivatedFollowing = creatorFollowingRepository.findByCreatorIdAndMemberId(creator.id!!, member.id!!) + assertNotNull(reactivatedFollowing) + assertEquals(inactiveFollowing.id, reactivatedFollowing!!.id) + assertTrue(reactivatedFollowing.isNotify) + assertTrue(reactivatedFollowing.isActive) + assertEquals(1, creatorFollowingRepository.findAll().size) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 요청에 유효하지 않은 id가 있으면 실패하고 신규 저장하지 않는다") + fun shouldFailAndSaveNothingWhenInvalidCreatorIdIsIncluded() { + val member = saveMember("viewer", MemberRole.USER) + val validCreator = saveMember("valid-creator", MemberRole.CREATOR) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[${validCreator.id},999999]}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message").value("크리에이터 정보를 확인해주세요.")) + + entityManager.flush() + entityManager.clear() + assertEquals(0, creatorFollowingRepository.findAll().size) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 비어 있으면 실패하고 신규 저장하지 않는다") + fun shouldRejectEmptyCreatorIdsAndSaveNothing() { + val member = saveMember("viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[]}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + + entityManager.flush() + entityManager.clear() + assertEquals(0, creatorFollowingRepository.findAll().size) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 null이면 실패하고 신규 저장하지 않는다") + fun shouldRejectNullCreatorIdsAndSaveNothing() { + val member = saveMember("viewer", MemberRole.USER) + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":null}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + + entityManager.flush() + entityManager.clear() + assertEquals(0, creatorFollowingRepository.findAll().size) + } + + @Test + @DisplayName("추천 크리에이터 동시 팔로우 요청 creatorIds가 50개를 초과하면 실패하고 신규 저장하지 않는다") + fun shouldRejectTooManyCreatorIdsAndSaveNothing() { + val member = saveMember("viewer", MemberRole.USER) + val creatorIds = (1..51).joinToString(",") { it.toString() } + entityManager.flush() + entityManager.clear() + + mockMvc.perform( + post("/api/v2/home/recommendations/creators/follow") + .with(user(MemberAdapter(member))) + .contentType(MediaType.APPLICATION_JSON) + .content("""{"creatorIds":[$creatorIds]}""") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.success").value(false)) + + entityManager.flush() + entityManager.clear() + assertEquals(0, creatorFollowingRepository.findAll().size) + } + + private fun saveMember(seed: String, role: MemberRole): Member { + return memberRepository.saveAndFlush( + Member( + email = "$seed@test.com", + password = "password", + nickname = seed, + role = role + ) + ) + } + + private fun saveFollowing(member: Member, creator: Member): CreatorFollowing { + return creatorFollowingRepository.saveAndFlush( + CreatorFollowing().apply { + this.member = member + this.creator = creator + } + ) + } +}