diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/CommonErrorCode.java b/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/CommonErrorCode.java index 9bac9f43..3643aab5 100644 --- a/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/CommonErrorCode.java +++ b/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/CommonErrorCode.java @@ -30,7 +30,8 @@ public enum CommonErrorCode implements ErrorCodeInterface { */ INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_INTERNAL_SERVER_ERROR", "Internal Server Error", "An unexpected error occurred"), NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "COMMON_NOT_IMPLEMENTED", "Not Implemented", "The server does not support the functionality required to fulfill the request."), - UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "COMMON_UNSUPPORTED_TYPE", "Unsupported Type", "The type specified is not supported."); + UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "COMMON_UNSUPPORTED_TYPE", "Unsupported Type", "The type specified is not supported."), + SENTENCES_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON_SENTENCES_NOT_FOUND", "Sentences Not Found", "No sentences available in the database"); private final HttpStatus status; private final String errorResponseCode; diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/controller/PostRecommendController.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/controller/PostRecommendController.java new file mode 100644 index 00000000..da23781c --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/controller/PostRecommendController.java @@ -0,0 +1,33 @@ +package com.depromeet.domains.recommend.controller; + +import com.depromeet.common.dto.ResponseDto; +import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto; +import com.depromeet.domains.recommend.service.PostRecommendService; +import com.depromeet.security.annotation.ReqUser; +import com.depromeet.user.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping +@RequiredArgsConstructor +@Tag(name = "๐Ÿ’โ€โ™€๏ธPost Recommend", description = "Post Recommend API") +public class PostRecommendController { + + private final PostRecommendService postRecommendService; + + @Operation(summary = "ํ™ˆ ํ™”๋ฉด ๋“œ๋ž ์œ ๋„ - ๋ฌด์ž‘์œ„ ๋ฌธ์žฅ ์ถ”์ฒœ") + @GetMapping("/post-recommend/random-sentence") + public ResponseEntity getRandomPhrase( + @ReqUser User user + ) { + var response = postRecommendService.getOneRandomSentence(user); + return ResponseDto.ok(response); + } + +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/dto/response/PostRecommendSentenceResponseDto.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/dto/response/PostRecommendSentenceResponseDto.java new file mode 100644 index 00000000..c8a97b21 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/dto/response/PostRecommendSentenceResponseDto.java @@ -0,0 +1,9 @@ +package com.depromeet.domains.recommend.dto.response; + +public record PostRecommendSentenceResponseDto( + String sentence +) { + public static PostRecommendSentenceResponseDto empty() { + return new PostRecommendSentenceResponseDto(null); + } +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/provider/RandomProvider.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/provider/RandomProvider.java new file mode 100644 index 00000000..e87c1ef5 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/provider/RandomProvider.java @@ -0,0 +1,20 @@ +package com.depromeet.domains.recommend.provider; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Random; + +@Component +public final class RandomProvider { + + private static final Random RANDOM = new Random(); + + public static T getRandomElement(List list) { + return list.get(RANDOM.nextInt(list.size())); + } + + private RandomProvider() { + } + +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/MemoryUserRecommendSendHistoryRepository.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/MemoryUserRecommendSendHistoryRepository.java new file mode 100644 index 00000000..bcac6e4e --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/MemoryUserRecommendSendHistoryRepository.java @@ -0,0 +1,27 @@ +package com.depromeet.domains.recommend.repository; + +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +@Repository +public class MemoryUserRecommendSendHistoryRepository implements UserRecommendSendHistoryRepository { + + private static Map store = new HashMap<>(); + + @Override + public void save(Long userId) { + store.put(userId, LocalDateTime.now()); + } + + @Override + public Boolean isSent(Long userId) { + return Optional.ofNullable(store.get(userId)) + .map(readTime -> !readTime.isBefore(LocalDateTime.now().minusDays(1))) + .orElse(false); + } + +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/PostRecommendSentenceRepository.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/PostRecommendSentenceRepository.java new file mode 100644 index 00000000..19d75e6a --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/PostRecommendSentenceRepository.java @@ -0,0 +1,7 @@ +package com.depromeet.domains.recommend.repository; + +import com.depromeet.recommend.post.PostRecommendSentence; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRecommendSentenceRepository extends JpaRepository { +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/UserRecommendSendHistoryRepository.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/UserRecommendSendHistoryRepository.java new file mode 100644 index 00000000..9a4d5e85 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/repository/UserRecommendSendHistoryRepository.java @@ -0,0 +1,6 @@ +package com.depromeet.domains.recommend.repository; + +public interface UserRecommendSendHistoryRepository { + void save(Long userId); + Boolean isSent(Long userId); +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/service/PostRecommendService.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/service/PostRecommendService.java new file mode 100644 index 00000000..9ebe94b0 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/recommend/service/PostRecommendService.java @@ -0,0 +1,39 @@ +package com.depromeet.domains.recommend.service; + +import com.depromeet.common.error.dto.CommonErrorCode; +import com.depromeet.common.error.exception.internal.NotFoundException; +import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto; +import com.depromeet.domains.recommend.provider.RandomProvider; +import com.depromeet.domains.recommend.repository.PostRecommendSentenceRepository; +import com.depromeet.domains.recommend.repository.UserRecommendSendHistoryRepository; +import com.depromeet.recommend.post.PostRecommendSentence; +import com.depromeet.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PostRecommendService { + + private final PostRecommendSentenceRepository postRecommendSentenceRepository; + private final UserRecommendSendHistoryRepository userRecommendSendHistoryRepository; + + public PostRecommendSentenceResponseDto getOneRandomSentence(User user) { + if (userRecommendSendHistoryRepository.isSent(user.getId())) { + return PostRecommendSentenceResponseDto.empty(); + } + String randomSentence = getRandomSentence(); + userRecommendSendHistoryRepository.save(user.getId()); + return new PostRecommendSentenceResponseDto(randomSentence); + } + + private String getRandomSentence() { + var postRecommendSentences = postRecommendSentenceRepository.findAll(); + if (postRecommendSentences.isEmpty()) { + throw new NotFoundException(CommonErrorCode.SENTENCES_NOT_FOUND); + } + PostRecommendSentence randomPostRecommendSentence = RandomProvider.getRandomElement(postRecommendSentences); + return randomPostRecommendSentence.getSentence(); + } + +} diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/security/config/WebSecurityConfig.java b/backend/streetdrop-api/src/main/java/com/depromeet/security/config/WebSecurityConfig.java index 3cd221f2..10be98f2 100644 --- a/backend/streetdrop-api/src/main/java/com/depromeet/security/config/WebSecurityConfig.java +++ b/backend/streetdrop-api/src/main/java/com/depromeet/security/config/WebSecurityConfig.java @@ -35,6 +35,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v2/users/**").authenticated() .requestMatchers("/items/**").authenticated() .requestMatchers("/pop-up/**").authenticated() + .requestMatchers("/post-recommend/**").authenticated() .requestMatchers(HttpMethod.POST, "notifications/tokens").authenticated() .anyRequest().permitAll() .and().exceptionHandling() diff --git a/backend/streetdrop-api/src/test/java/unit/domains/recommend/controller/PostRecommendControllerTest.java b/backend/streetdrop-api/src/test/java/unit/domains/recommend/controller/PostRecommendControllerTest.java new file mode 100644 index 00000000..c1e1594c --- /dev/null +++ b/backend/streetdrop-api/src/test/java/unit/domains/recommend/controller/PostRecommendControllerTest.java @@ -0,0 +1,87 @@ +package unit.domains.recommend.controller; + +import com.depromeet.common.error.GlobalExceptionHandler; +import com.depromeet.domains.recommend.controller.PostRecommendController; +import com.depromeet.domains.recommend.dto.response.PostRecommendSentenceResponseDto; +import com.depromeet.domains.recommend.service.PostRecommendService; +import com.depromeet.user.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import unit.annotation.MockAnonymousUser; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ContextConfiguration(classes = {PostRecommendController.class, ValidationAutoConfiguration.class}) +@WebMvcTest(controllers = {PostRecommendController.class}) +@Import({PostRecommendController.class, GlobalExceptionHandler.class}) +@DisplayName("[API][Controller] PostRecommendController ํ…Œ์ŠคํŠธ") +public class PostRecommendControllerTest { + + @Autowired + MockMvc mvc; + + @MockBean + PostRecommendService postRecommendService; + + User user; + + @BeforeEach + void setUp() { + user = User.builder() + .idfv("new-idfv") + .build(); + } + + @DisplayName("[GET] ํ™ˆ ํ™”๋ฉด ๋“œ๋ž ์œ ๋„ - ๋ฌด์ž‘์œ„ ๋ฌธ์žฅ ์ถ”์ฒœ") + @Nested + @MockAnonymousUser + class GetRandomSentenceTest { + @Nested + @DisplayName("์„ฑ๊ณต") + @MockAnonymousUser + class Success { + @DisplayName("๋ฌด์ž‘์œ„ ์ถ”์ฒœ ๋ฌธ์žฅ 1๊ฐœ ์กฐํšŒ") + @Test + void getOneRandomSentenceSuccess1() throws Exception { + + var randomSentence = new PostRecommendSentenceResponseDto("random sentence"); + when(postRecommendService.getOneRandomSentence(any(User.class))).thenReturn(randomSentence); + + var response = mvc.perform( + get("/post-recommend/random-sentence") + .header("x-sdp-idfv", "new-idfv") + ); + response.andExpect(status().isOk()) + .andExpect(jsonPath("$.sentence").value("random sentence")); + } + + @DisplayName("๋ฌด์ž‘์œ„ ์ถ”์ฒœ ๋ฌธ์žฅ์ด ์—†๋Š” ๊ฒฝ์šฐ") + @Test + void getOneRandomSentenceSuccess2() throws Exception { + + var emptySentence = PostRecommendSentenceResponseDto.empty(); + when(postRecommendService.getOneRandomSentence(any(User.class))).thenReturn(emptySentence); + + var response = mvc.perform( + get("/post-recommend/random-sentence") + .header("x-sdp-idfv", "new-idfv") + ); + response.andExpect(status().isOk()) + .andExpect(jsonPath("$.sentence").isEmpty()); + } + } + } +} diff --git a/backend/streetdrop-api/src/test/java/unit/domains/recommend/service/PostRecommendServiceTest.java b/backend/streetdrop-api/src/test/java/unit/domains/recommend/service/PostRecommendServiceTest.java new file mode 100644 index 00000000..5d4111a3 --- /dev/null +++ b/backend/streetdrop-api/src/test/java/unit/domains/recommend/service/PostRecommendServiceTest.java @@ -0,0 +1,100 @@ +package unit.domains.recommend.service; + +import com.depromeet.common.error.exception.internal.NotFoundException; +import com.depromeet.domains.recommend.repository.PostRecommendSentenceRepository; +import com.depromeet.domains.recommend.repository.UserRecommendSendHistoryRepository; +import com.depromeet.domains.recommend.service.PostRecommendService; +import com.depromeet.recommend.post.PostRecommendSentence; +import com.depromeet.user.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("[Service] PostRecommendService ํ…Œ์ŠคํŠธ") +public class PostRecommendServiceTest { + + @InjectMocks + private PostRecommendService postRecommendService; + + @Mock + private PostRecommendSentenceRepository postRecommendSentenceRepository; + + @Mock + private UserRecommendSendHistoryRepository userRecommendSendHistoryRepository; + + User user; + + @BeforeEach + void setUp() throws NoSuchFieldException, IllegalAccessException { + user = User.builder().build(); + Field userIdField = User.class.getDeclaredField("id"); + userIdField.setAccessible(true); + userIdField.set(user, 1L); + } + + @DisplayName("๋ฌด์ž‘์œ„ ๋ฌธ์žฅ ์ถ”์ฒœ") + @Nested + class GetOneRandomSentenceTest { + @Nested + @DisplayName("์„ฑ๊ณต") + class Success { + @DisplayName("์ด๋ฏธ ์ถ”์ฒœ ๋ฌธ์žฅ์„ ๋ฐ›์€ ์‚ฌ์šฉ์ž์ธ ๊ฒฝ์šฐ") + @Test + void getOneRandomSentenceSuccess1() { + given(userRecommendSendHistoryRepository.isSent(user.getId())).willReturn(true); + var result = postRecommendService.getOneRandomSentence(user); + + assertThat(result.sentence()).isNull(); + } + + @DisplayName("๋ฌด์ž‘์œ„ ์ถ”์ฒœ ๋ฌธ์žฅ 1๊ฐœ ์กฐํšŒ") + @Test + void getOneRandomSentenceSuccess2() { + List sentences = List.of( + new PostRecommendSentence("First sentence"), + new PostRecommendSentence("Second sentence"), + new PostRecommendSentence("Third sentence") + ); + + given(postRecommendSentenceRepository.findAll()).willReturn(sentences); + given(userRecommendSendHistoryRepository.isSent(user.getId())).willReturn(false); + var result = postRecommendService.getOneRandomSentence(user); + + assertThat(result).isNotNull(); + assertThat(result.sentence()).isIn( + "First sentence", + "Second sentence", + "Third sentence" + ); + } + } + + @Nested + @DisplayName("์‹คํŒจ") + class Fail { + @DisplayName("์ €์žฅ์†Œ์— ์ถ”์ฒœ ๋ฌธ์žฅ์ด ์—†๋Š” ๊ฒฝ์šฐ") + @Test + void getOneRandomSentenceFail() { + given(userRecommendSendHistoryRepository.isSent(user.getId())).willReturn(false); + given(postRecommendSentenceRepository.findAll()).willReturn(List.of()); + + assertThatThrownBy(() -> postRecommendService.getOneRandomSentence(user)) + .isInstanceOf(NotFoundException.class); + } + } + } + +} diff --git a/backend/streetdrop-domain/src/main/java/com/depromeet/recommend/post/PostRecommendSentence.java b/backend/streetdrop-domain/src/main/java/com/depromeet/recommend/post/PostRecommendSentence.java new file mode 100644 index 00000000..74880669 --- /dev/null +++ b/backend/streetdrop-domain/src/main/java/com/depromeet/recommend/post/PostRecommendSentence.java @@ -0,0 +1,32 @@ +package com.depromeet.recommend.post; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +public class PostRecommendSentence { + + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "post_recommend_sentence_id") + private Long id; + + @Column(nullable = false) + private String sentence; + + @Builder + public PostRecommendSentence(String sentence) { + this.sentence = sentence; + } + +} diff --git a/backend/streetdrop-domain/src/main/resources/db/migration/V20241017__add_post_recommend_sentence_table.sql b/backend/streetdrop-domain/src/main/resources/db/migration/V20241017__add_post_recommend_sentence_table.sql new file mode 100644 index 00000000..ea092cd2 --- /dev/null +++ b/backend/streetdrop-domain/src/main/resources/db/migration/V20241017__add_post_recommend_sentence_table.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS post_recommend_sentence ( + post_recommend_sentence_id BIGINT NOT NULL AUTO_INCREMENT, + sentence VARCHAR(255) NOT NULL, + PRIMARY KEY (post_recommend_sentence_id) +) \ No newline at end of file