diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java new file mode 100644 index 0000000..c14849b --- /dev/null +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentController.java @@ -0,0 +1,50 @@ +package ru.practicum.explorewithme.main.controller.priv; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +import ru.practicum.explorewithme.main.dto.UpdateCommentDto; +import ru.practicum.explorewithme.main.service.CommentService; + +@RestController +@RequestMapping("/users/{userId}/comments") +@RequiredArgsConstructor +@Validated +@Slf4j +public class PrivateCommentController { + + private final CommentService commentService; + + @PostMapping + public ResponseEntity createComment( + @PathVariable @Positive Long userId, + @RequestParam @Positive Long eventId, + @Valid @RequestBody NewCommentDto newCommentDto) { + + log.info("Создание нового комментария {} зарегистрированным пользователем c id {} " + + "к событию с id {}", newCommentDto, userId, eventId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(commentService.addComment(userId, eventId, newCommentDto)); + } + + @PatchMapping("/{commentId}") + @ResponseStatus(HttpStatus.OK) + public ResponseEntity updateComment( + @PathVariable @Positive Long userId, + @PathVariable @Positive Long commentId, + @Valid @RequestBody UpdateCommentDto updateCommentDto) { + + log.info("Обновление комментария c id {} пользователем c id {}," + + " новый комментарий {}", commentId, userId, updateCommentDto); + + return ResponseEntity.status(HttpStatus.OK).body(commentService.updateUserComment(userId, commentId, updateCommentDto)); + } +} diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java index 19d0e41..79bce6a 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentService.java @@ -1,6 +1,8 @@ package ru.practicum.explorewithme.main.service; import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +import ru.practicum.explorewithme.main.dto.UpdateCommentDto; import ru.practicum.explorewithme.main.service.params.PublicCommentParameters; import java.util.List; @@ -8,4 +10,8 @@ public interface CommentService { List getCommentsForEvent(Long eventId, PublicCommentParameters commentParameters); + + CommentDto addComment(Long userId, Long eventId, NewCommentDto newCommentDto); + + CommentDto updateUserComment(Long userId, Long commentId, UpdateCommentDto updateCommentDto); } diff --git a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java index 8691914..6c6e63d 100644 --- a/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java +++ b/main-service/src/main/java/ru/practicum/explorewithme/main/service/CommentServiceImpl.java @@ -6,21 +6,29 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +import ru.practicum.explorewithme.main.dto.UpdateCommentDto; +import ru.practicum.explorewithme.main.error.BusinessRuleViolationException; import ru.practicum.explorewithme.main.error.EntityNotFoundException; import ru.practicum.explorewithme.main.mapper.CommentMapper; import ru.practicum.explorewithme.main.model.Comment; import ru.practicum.explorewithme.main.model.Event; import ru.practicum.explorewithme.main.model.EventState; +import ru.practicum.explorewithme.main.model.User; import ru.practicum.explorewithme.main.repository.CommentRepository; import ru.practicum.explorewithme.main.repository.EventRepository; +import ru.practicum.explorewithme.main.repository.UserRepository; import ru.practicum.explorewithme.main.service.params.PublicCommentParameters; +import java.time.LocalDateTime; +import java.util.Optional; import java.util.List; @Service @RequiredArgsConstructor public class CommentServiceImpl implements CommentService { + private final UserRepository userRepository; private final CommentRepository commentRepository; private final EventRepository eventRepository; private final CommentMapper commentMapper; @@ -43,4 +51,60 @@ public List getCommentsForEvent(Long eventId, PublicCommentParameter return commentMapper.toDtoList(result); } + + @Override + @Transactional + public CommentDto addComment(Long userId, Long eventId, NewCommentDto newCommentDto) { + + User author = userRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException("Пользователь с id " + userId + " не найден")); + + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new EntityNotFoundException("Событие с id " + eventId + " не найдено")); + + if (!event.getState().equals(EventState.PUBLISHED)) { + throw new BusinessRuleViolationException("Событие еще не опубликовано"); + } + + if (!event.isCommentsEnabled()) { + throw new BusinessRuleViolationException("Комментарии запрещены"); + } + + Comment comment = commentMapper.toComment(newCommentDto); + + comment.setAuthor(author); + comment.setEvent(event); + + return commentMapper.toDto(commentRepository.save(comment)); + } + + @Override + @Transactional + public CommentDto updateUserComment(Long userId, Long commentId, UpdateCommentDto updateCommentDto) { + + Optional comment = commentRepository.findById(commentId); + + if (comment.isEmpty()) { + throw new EntityNotFoundException("Комментарий с id" + commentId + " не найден"); + } + + Comment existedComment = comment.get(); + + if (!existedComment.getAuthor().getId().equals(userId)) { + throw new EntityNotFoundException("Искомый комментарий с id " + commentId + " пользователя с id " + userId + "не найден"); + } + + if (existedComment.isDeleted() == true) { + throw new BusinessRuleViolationException("Редактирование невозможно. Комментарий удален"); + } + + if (existedComment.getCreatedOn().isBefore(LocalDateTime.now().minusHours(6))) { + throw new BusinessRuleViolationException("Время для редактирования истекло"); + } + + existedComment.setText(updateCommentDto.getText()); + existedComment.setEdited(true); + + return commentMapper.toDto(commentRepository.save(existedComment)); + } } diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java new file mode 100644 index 0000000..d6d7cf5 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/controller/priv/PrivateCommentControllerTest.java @@ -0,0 +1,216 @@ +package ru.practicum.explorewithme.main.controller.priv; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +import ru.practicum.explorewithme.main.dto.UpdateCommentDto; +import ru.practicum.explorewithme.main.dto.UserShortDto; +import ru.practicum.explorewithme.main.service.CommentService; + +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; // <-- для post-запроса +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + + +@WebMvcTest(PrivateCommentController.class) +public class PrivateCommentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private CommentService commentService; + + private ObjectMapper objectMapper; + + private final Long userId = 1L; + private final Long eventId = 100L; + + private NewCommentDto newCommentDto; + private CommentDto commentDto; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + objectMapper.findAndRegisterModules(); + + newCommentDto = NewCommentDto.builder() + .text("Test comment text") + .build(); + + UserShortDto author = UserShortDto.builder() + .id(2L) + .name("testUser") + .build(); + + commentDto = CommentDto.builder() + .id(10L) + .text(newCommentDto.getText()) + .author(author) + .eventId(eventId) + .createdOn(LocalDateTime.now()) + .updatedOn(LocalDateTime.now()) + .isEdited(false) + .build(); + } + + @Nested + @DisplayName("Набор тестов для метода createComment") + class CreateComment { + + @Test + void createComment_whenValidInput_thenReturnsCreatedComment() throws Exception { + when(commentService.addComment(eq(userId), eq(eventId), any(NewCommentDto.class))) + .thenReturn(commentDto); + + mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newCommentDto))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(commentDto.getId())) + .andExpect(jsonPath("$.text").value(commentDto.getText())) + .andExpect(jsonPath("$.eventId").value(eventId)) + .andExpect(jsonPath("$.author.id").value(commentDto.getAuthor().getId())) + .andExpect(jsonPath("$.author.name").value(commentDto.getAuthor().getName())) + .andExpect(jsonPath("$.isEdited").value(false)); + } + + @Test + void createComment_whenInvalidText_thenReturnsBadRequest() throws Exception { + NewCommentDto invalidDto = NewCommentDto.builder() + .text("") + .build(); + + mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidDto))) + .andExpect(status().isBadRequest()); + } + + @Test + void createComment_whenNegativeUserId_thenReturnsBadRequest() throws Exception { + mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", -1, eventId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newCommentDto))) + .andExpect(status().isBadRequest()); + } + + @Test + void createComment_whenNegativeEventId_thenReturnsBadRequest() throws Exception { + mockMvc.perform(post("/users/{userId}/comments?eventId={eventId}", userId, -1) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(newCommentDto))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("Набор тестов для метода updateComment") + class UpdateComment { + + @Test + void updateComment_shouldReturnUpdatedComment_whenInputIsValid() throws Exception { + Long commentId = commentDto.getId(); + + UpdateCommentDto updateCommentDto = UpdateCommentDto.builder() + .text("Updated text") + .build(); + + CommentDto updatedComment = CommentDto.builder() + .id(commentId) + .text(updateCommentDto.getText()) + .author(commentDto.getAuthor()) + .eventId(eventId) + .createdOn(commentDto.getCreatedOn()) + .updatedOn(commentDto.getUpdatedOn()) + .isEdited(true) + .build(); + + when(commentService.updateUserComment(eq(userId), eq(commentId), any(UpdateCommentDto.class))) + .thenReturn(updatedComment); + + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateCommentDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(commentId)) + .andExpect(jsonPath("$.text").value(updateCommentDto.getText())) + .andExpect(jsonPath("$.author.id").value(commentDto.getAuthor().getId())) + .andExpect(jsonPath("$.isEdited").value(true)); + + verify(commentService, times(1)) + .updateUserComment(eq(userId), eq(commentId), any(UpdateCommentDto.class)); + } + + @Test + void updateComment_shouldReturnBadRequest_whenPathVariablesInvalid() throws Exception { + UpdateCommentDto updateCommentDto = UpdateCommentDto.builder() + .text("Comment text") + .build(); + + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", -1, 10) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateCommentDto))) + .andExpect(status().isBadRequest()); + + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", 1, -10) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateCommentDto))) + .andExpect(status().isBadRequest()); + } + + @Test + void updateComment_shouldReturnBadRequest_whenBodyTextBlank() throws Exception { + UpdateCommentDto updateCommentDto = UpdateCommentDto.builder() + .text(" ") + .build(); + + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateCommentDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0]").exists()) + .andExpect(jsonPath("$.errors[0]").value("text: Comment text cannot be blank.")); + } + + @Test + void updateComment_shouldReturnBadRequest_whenBodyTextTooLong() throws Exception { + String longText = "a".repeat(2001); + UpdateCommentDto updateCommentDto = UpdateCommentDto.builder() + .text(longText) + .build(); + + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateCommentDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0]").exists()) + .andExpect(jsonPath("$.errors[0]").value("text: Comment text must be between 1 and 2000 characters.")); + } + + @Test + void updateComment_shouldReturnBadRequest_whenBodyEmpty() throws Exception { + mockMvc.perform(patch("/users/{userId}/comments/{commentId}", userId, commentDto.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errors[0]").exists()) + .andExpect(jsonPath("$.errors[0]").value("text: Comment text cannot be blank.")); + } + } +} diff --git a/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java new file mode 100644 index 0000000..7ebbc11 --- /dev/null +++ b/main-service/src/test/java/ru/practicum/explorewithme/main/service/CommentServiceImplTest.java @@ -0,0 +1,219 @@ +package ru.practicum.explorewithme.main.service; + +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import ru.practicum.explorewithme.main.dto.CommentDto; +import ru.practicum.explorewithme.main.dto.NewCommentDto; +import ru.practicum.explorewithme.main.dto.UpdateCommentDto; +import ru.practicum.explorewithme.main.error.BusinessRuleViolationException; +import ru.practicum.explorewithme.main.error.EntityNotFoundException; +import ru.practicum.explorewithme.main.mapper.CommentMapper; +import ru.practicum.explorewithme.main.model.Comment; +import ru.practicum.explorewithme.main.model.Event; +import ru.practicum.explorewithme.main.model.EventState; +import ru.practicum.explorewithme.main.model.User; +import ru.practicum.explorewithme.main.repository.CommentRepository; +import ru.practicum.explorewithme.main.repository.EventRepository; +import ru.practicum.explorewithme.main.repository.UserRepository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class CommentServiceImplTest { + + @Mock + private UserRepository userRepository; + @Mock + private EventRepository eventRepository; + @Mock + private CommentMapper commentMapper; + @Mock + private CommentRepository commentRepository; + + @InjectMocks + private CommentServiceImpl commentService; + + private long userId; + private long eventId; + private long commentId; + private User user; + private Event event; + private Comment comment; + + @BeforeEach + void setUp() { + userId = 1L; + eventId = 2L; + commentId = 10L; + user = new User(); + user.setId(userId); + + event = new Event(); + event.setId(eventId); + + comment = new Comment(); + comment.setId(commentId); + comment.setAuthor(user); + comment.setDeleted(false); + comment.setEdited(false); + comment.setText("Old text"); + comment.setCreatedOn(LocalDateTime.now().minusHours(5)); + } + + @Nested + @DisplayName("Набор тестов для метода addComment") + class AddComment { + + @Test + void addComment_success() { + NewCommentDto newCommentDto = new NewCommentDto(); + event.setState(EventState.PUBLISHED); + event.setCommentsEnabled(true); + + CommentDto commentDto = new CommentDto(); + + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(eventRepository.findById(eventId)).thenReturn(Optional.of(event)); + when(commentMapper.toComment(newCommentDto)).thenReturn(comment); + when(commentRepository.save(any(Comment.class))).thenReturn(comment); + when(commentMapper.toDto(any(Comment.class))).thenReturn(commentDto); + + CommentDto result = commentService.addComment(userId, eventId, newCommentDto); + + assertEquals(commentDto, result); + verify(commentRepository, times(1)).save(comment); + assertEquals(user, comment.getAuthor()); + assertEquals(event, comment.getEvent()); + } + + @Test + void addComment_userNotFound() { + when(userRepository.findById(userId)).thenReturn(Optional.empty()); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> commentService.addComment(userId, 2L, new NewCommentDto())); + assertTrue(ex.getMessage().contains("Пользователь с id " + userId + " не найден")); + } + + @Test + void addComment_eventNotFound() { + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(eventRepository.findById(eventId)).thenReturn(Optional.empty()); + + EntityNotFoundException ex = assertThrows(EntityNotFoundException.class, + () -> commentService.addComment(userId, eventId, new NewCommentDto())); + assertTrue(ex.getMessage().contains("Событие с id " + eventId + " не найден")); + } + + @Test + void addComment_eventNotPublished() { + event.setState(EventState.PENDING); // не опубликовано + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(eventRepository.findById(eventId)).thenReturn(Optional.of(event)); + + BusinessRuleViolationException ex = assertThrows(BusinessRuleViolationException.class, + () -> commentService.addComment(userId, eventId, new NewCommentDto())); + assertEquals("Событие еще не опубликовано", ex.getMessage()); + } + + @Test + void addComment_commentsDisabled() { + event.setState(EventState.PUBLISHED); + event.setCommentsEnabled(false); // Комментарии запрещены + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(eventRepository.findById(eventId)).thenReturn(Optional.of(event)); + + BusinessRuleViolationException ex = assertThrows(BusinessRuleViolationException.class, + () -> commentService.addComment(userId, eventId, new NewCommentDto())); + assertEquals("Комментарии запрещены", ex.getMessage()); + } + } + + @Nested + @DisplayName("Набор тестов для метода updateUserComment") + class UpdateUserComment { + + @Test + void updateUserComment_shouldUpdateCommentAndReturnDto() { + UpdateCommentDto updateCommentDto = new UpdateCommentDto(); + updateCommentDto.setText("Updated text"); + + CommentDto expectedDto = new CommentDto(); + expectedDto.setId(commentId); + expectedDto.setText("Updated text"); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + when(commentMapper.toDto(any(Comment.class))).thenReturn(expectedDto); + when(commentRepository.save(any(Comment.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + CommentDto result = commentService.updateUserComment(userId, commentId, updateCommentDto); + + Assertions.assertEquals("Updated text", result.getText()); + Assertions.assertTrue(comment.isEdited()); + verify(commentRepository).save(comment); + } + + @Test + void updateUserComment_shouldThrowIfCommentNotFound() { + when(commentRepository.findById(commentId)).thenReturn(Optional.empty()); + UpdateCommentDto dto = new UpdateCommentDto(); + + EntityNotFoundException ex = Assertions.assertThrows( + EntityNotFoundException.class, + () -> commentService.updateUserComment(userId, commentId, dto) + ); + Assertions.assertTrue(ex.getMessage().contains("не найден")); + } + + @Test + void updateUserComment_shouldThrowIfUserIsNotAuthor() { + User anotherUser = new User(); + anotherUser.setId(111L); + comment.setAuthor(anotherUser); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + UpdateCommentDto dto = new UpdateCommentDto(); + + EntityNotFoundException ex = Assertions.assertThrows( + EntityNotFoundException.class, + () -> commentService.updateUserComment(userId, commentId, dto) + ); + Assertions.assertTrue(ex.getMessage().contains("пользователя с id")); + } + + @Test + void updateUserComment_shouldThrowIfDeleted() { + comment.setDeleted(true); + + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + UpdateCommentDto dto = new UpdateCommentDto(); + + BusinessRuleViolationException ex = Assertions.assertThrows( + BusinessRuleViolationException.class, + () -> commentService.updateUserComment(userId, commentId, dto) + ); + Assertions.assertTrue(ex.getMessage().contains("удален")); + } + + @Test + void updateUserComment_shouldThrowIfTooLate() { + + comment.setCreatedOn(LocalDateTime.now().minusHours(7)); + when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment)); + UpdateCommentDto dto = new UpdateCommentDto(); + + BusinessRuleViolationException ex = Assertions.assertThrows( + BusinessRuleViolationException.class, + () -> commentService.updateUserComment(userId, commentId, dto) + ); + Assertions.assertTrue(ex.getMessage().contains("Время для редактирования истекло")); + } + } +} \ No newline at end of file