Skip to content

Commit

Permalink
✨ feat(api): add post recommendation sentence API (#517)
Browse files Browse the repository at this point in the history
* ✨ feat(api): add post recommendation sentence API

* ✅ test(api): add post recommendation sentence

* ✨ feat(api): get once a day

* ✅ test(api): get once a day

* ♻️ refactor(api): remove unnecessary codes

* ♻️ refactor(api): rename UserTimestampRepository -> UserReadRepository

* ♻️ refactor(api): rename UserReadRepository -> userRecommendSendHistoryRepository
  • Loading branch information
siyeonSon authored Oct 21, 2024
1 parent 30df5d3 commit 698f759
Show file tree
Hide file tree
Showing 13 changed files with 368 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PostRecommendSentenceResponseDto> getRandomPhrase(
@ReqUser User user
) {
var response = postRecommendService.getOneRandomSentence(user);
return ResponseDto.ok(response);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.depromeet.domains.recommend.dto.response;

public record PostRecommendSentenceResponseDto(
String sentence
) {
public static PostRecommendSentenceResponseDto empty() {
return new PostRecommendSentenceResponseDto(null);
}
}
Original file line number Diff line number Diff line change
@@ -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> T getRandomElement(List<T> list) {
return list.get(RANDOM.nextInt(list.size()));
}

private RandomProvider() {
}

}
Original file line number Diff line number Diff line change
@@ -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<Long, LocalDateTime> 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);
}

}
Original file line number Diff line number Diff line change
@@ -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<PostRecommendSentence, Long> {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.depromeet.domains.recommend.repository;

public interface UserRecommendSendHistoryRepository {
void save(Long userId);
Boolean isSent(Long userId);
}
Original file line number Diff line number Diff line change
@@ -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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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());
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<PostRecommendSentence> 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);
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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;
}

}
Original file line number Diff line number Diff line change
@@ -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)
)

0 comments on commit 698f759

Please sign in to comment.