diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 97250b4f7..d826373cf 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -150,6 +150,11 @@ public enum SwaggerResponseDescription { RECORD_NOT_FOUND, RECORD_ACCESS_FORBIDDEN ))), + RECORD_UPDATE(new LinkedHashSet<>(Set.of( + ROOM_ACCESS_FORBIDDEN, + RECORD_NOT_FOUND, + RECORD_ACCESS_FORBIDDEN + ))), // Vote VOTE_CREATE(new LinkedHashSet<>(Set.of( @@ -172,6 +177,11 @@ public enum SwaggerResponseDescription { VOTE_NOT_FOUND, VOTE_ACCESS_FORBIDDEN ))), + VOTE_UPDATE(new LinkedHashSet<>(Set.of( + ROOM_ACCESS_FORBIDDEN, + VOTE_NOT_FOUND, + VOTE_ACCESS_FORBIDDEN + ))), // FEED diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java index fd7aebae0..18c8f6da7 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/RoomPostCommandController.java @@ -7,12 +7,7 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; -import konkuk.thip.roompost.adapter.in.web.request.AttendanceCheckCreateRequest; -import konkuk.thip.roompost.adapter.in.web.response.AttendanceCheckCreateResponse; -import konkuk.thip.roompost.application.port.in.AttendanceCheckCreateUseCase; -import konkuk.thip.roompost.adapter.in.web.request.RecordCreateRequest; -import konkuk.thip.roompost.adapter.in.web.request.VoteCreateRequest; -import konkuk.thip.roompost.adapter.in.web.request.VoteRequest; +import konkuk.thip.roompost.adapter.in.web.request.*; import konkuk.thip.roompost.adapter.in.web.response.*; import konkuk.thip.roompost.application.port.in.*; import konkuk.thip.roompost.application.port.in.dto.record.RecordDeleteCommand; @@ -29,6 +24,7 @@ public class RoomPostCommandController { private final RecordCreateUseCase recordCreateUseCase; private final RecordDeleteUseCase recordDeleteUseCase; + private final RoomPostUpdateUseCase roomPostUpdateUseCase; private final VoteCreateUseCase voteCreateUseCase; private final VoteDeleteUseCase voteDeleteUseCase; @@ -133,4 +129,38 @@ public BaseResponse createFeed( attendanceCheckCreateUseCase.create(request.toCommand(userId, roomId)) )); } + + @Operation( + summary = "기록 수정", + description = "사용자가 방 기록을 수정합니다. (기록 내용만 수정 가능)" + ) + @PatchMapping("/rooms/{roomId}/records/{recordId}") + @ExceptionDescription(RECORD_UPDATE) + public BaseResponse updateRecord( + @Parameter(hidden = true) @UserId Long userId, + @Parameter(description = "수정할 방 ID", example = "1") @PathVariable Long roomId, + @Parameter(description = "수정할 기록 ID", example = "1") @PathVariable Long recordId, + @RequestBody @Valid final RecordUpdateRequest request + ) { + return BaseResponse.ok(RecordUpdateResponse.of( + roomPostUpdateUseCase.updateRecord(request.toCommand(userId, roomId, recordId)) + )); + } + + @Operation( + summary = "투표 수정", + description = "사용자가 방 투표를 수정합니다. (투표 내용만 수정 가능)" + ) + @PatchMapping("/rooms/{roomId}/votes/{voteId}") + @ExceptionDescription(VOTE_UPDATE) + public BaseResponse updateVote( + @Parameter(hidden = true) @UserId Long userId, + @Parameter(description = "수정할 방 ID", example = "1") @PathVariable Long roomId, + @Parameter(description = "수정할 투표 ID", example = "1") @PathVariable Long voteId, + @RequestBody @Valid final VoteUpdateRequest request + ) { + return BaseResponse.ok(VoteUpdateResponse.of( + roomPostUpdateUseCase.updateVote(request.toCommand(userId, roomId, voteId)) + )); + } } diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordUpdateRequest.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordUpdateRequest.java new file mode 100644 index 000000000..68320ac05 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/RecordUpdateRequest.java @@ -0,0 +1,22 @@ +package konkuk.thip.roompost.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import konkuk.thip.roompost.application.port.in.dto.record.RecordUpdateCommand; + +public record RecordUpdateRequest( + @Schema(description = "기록 내용", example = "띱은 최고의 서비스인가?") + @NotBlank(message = "기록 내용은 필수입니다.") + @Size(max = 500, message = "기록 내용은 최대 500자 입니다.") + String content +) { + public RecordUpdateCommand toCommand(Long userId, Long roomId, Long recordId) { + return new RecordUpdateCommand( + roomId, + recordId, + userId, + this.content + ); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/request/VoteUpdateRequest.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/VoteUpdateRequest.java new file mode 100644 index 000000000..fbc533b1e --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/request/VoteUpdateRequest.java @@ -0,0 +1,22 @@ +package konkuk.thip.roompost.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import konkuk.thip.roompost.application.port.in.dto.vote.VoteUpdateCommand; + +public record VoteUpdateRequest( + @Schema(description = "투표 내용", example = "띱은 최고의 서비스인가?") + @NotBlank(message = "투표 내용은 필수입니다.") + @Size(max = 20, message = "투표 내용은 최대 20자 입니다.") + String content +) { + public VoteUpdateCommand toCommand(Long userId, Long roomId, Long voteId) { + return new VoteUpdateCommand( + roomId, + voteId, + userId, + this.content + ); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordUpdateResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordUpdateResponse.java new file mode 100644 index 000000000..1d17fe40b --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/RecordUpdateResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.roompost.adapter.in.web.response; + +public record RecordUpdateResponse( + Long roomId +) { + public static RecordUpdateResponse of(Long roomId) { + return new RecordUpdateResponse(roomId); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/in/web/response/VoteUpdateResponse.java b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/VoteUpdateResponse.java new file mode 100644 index 000000000..946aacd15 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/adapter/in/web/response/VoteUpdateResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.roompost.adapter.in.web.response; + +public record VoteUpdateResponse( + Long roomId +) { + public static VoteUpdateResponse of(Long roomId) { + return new VoteUpdateResponse(roomId); + } +} diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java index 015bbf797..0d0c20dd5 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/RecordCommandPersistenceAdapter.java @@ -28,7 +28,7 @@ public class RecordCommandPersistenceAdapter implements RecordCommandPort { private final RecordMapper recordMapper; @Override - public Long saveRecord(Record record) { + public Long save(Record record) { UserJpaEntity userJpaEntity = userJpaRepository.findById(record.getCreatorId()).orElseThrow( () -> new EntityNotFoundException(USER_NOT_FOUND) ); diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/RoomPostUpdateUseCase.java b/src/main/java/konkuk/thip/roompost/application/port/in/RoomPostUpdateUseCase.java new file mode 100644 index 000000000..6498710b5 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/RoomPostUpdateUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.roompost.application.port.in; + +import konkuk.thip.roompost.application.port.in.dto.record.RecordUpdateCommand; +import konkuk.thip.roompost.application.port.in.dto.vote.VoteUpdateCommand; + +public interface RoomPostUpdateUseCase { + Long updateRecord(RecordUpdateCommand command); + Long updateVote(VoteUpdateCommand command); +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordUpdateCommand.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordUpdateCommand.java new file mode 100644 index 000000000..c037c14a7 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/record/RecordUpdateCommand.java @@ -0,0 +1,9 @@ +package konkuk.thip.roompost.application.port.in.dto.record; + +public record RecordUpdateCommand( + Long roomId, + Long postId, + Long userId, + String content +) { +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteUpdateCommand.java b/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteUpdateCommand.java new file mode 100644 index 000000000..379182738 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/port/in/dto/vote/VoteUpdateCommand.java @@ -0,0 +1,9 @@ +package konkuk.thip.roompost.application.port.in.dto.vote; + +public record VoteUpdateCommand( + Long roomId, + Long postId, + Long userId, + String content +) { +} diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java index cf1994e38..16b5ceb9a 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/RecordCommandPort.java @@ -10,7 +10,7 @@ public interface RecordCommandPort { - Long saveRecord(Record record); + Long save(Record record); void update(Record record); diff --git a/src/main/java/konkuk/thip/roompost/application/service/RecordCreateService.java b/src/main/java/konkuk/thip/roompost/application/service/RecordCreateService.java index c30a9e02d..e3224f6f6 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/RecordCreateService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/RecordCreateService.java @@ -58,7 +58,7 @@ public RecordCreateResult createRecord(RecordCreateCommand command) { validateRecord(record, book); // 4. 문제없는 경우 Record 저장 - Long newRecordId = recordCommandPort.saveRecord(record); + Long newRecordId = recordCommandPort.save(record); // 5. RoomParticipant, Room progress 정보 update roomProgressManager.updateUserAndRoomProgress(roomParticipant, room, book, record.getPage()); diff --git a/src/main/java/konkuk/thip/roompost/application/service/RoomPostUpdateService.java b/src/main/java/konkuk/thip/roompost/application/service/RoomPostUpdateService.java new file mode 100644 index 000000000..0bfef5ae1 --- /dev/null +++ b/src/main/java/konkuk/thip/roompost/application/service/RoomPostUpdateService.java @@ -0,0 +1,53 @@ +package konkuk.thip.roompost.application.service; + +import konkuk.thip.room.application.service.validator.RoomParticipantValidator; +import konkuk.thip.roompost.application.port.in.RoomPostUpdateUseCase; +import konkuk.thip.roompost.application.port.in.dto.record.RecordUpdateCommand; +import konkuk.thip.roompost.application.port.in.dto.vote.VoteUpdateCommand; +import konkuk.thip.roompost.application.port.out.RecordCommandPort; +import konkuk.thip.roompost.application.port.out.VoteCommandPort; +import konkuk.thip.roompost.domain.Record; +import konkuk.thip.roompost.domain.Vote; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RoomPostUpdateService implements RoomPostUpdateUseCase { + + private final RoomParticipantValidator roomParticipantValidator; + private final RecordCommandPort recordCommandPort; + private final VoteCommandPort voteCommandPort; + + @Override + public Long updateRecord(RecordUpdateCommand command) { + // 1. 사용자가 방의 참가자인지 검증 + roomParticipantValidator.validateUserIsRoomMember(command.roomId(), command.userId()); + + // 2. Record 조회 + Record record = recordCommandPort.getByIdOrThrow(command.postId()); + + // 3. Record 수정 + record.updateRecord(command.userId(), command.roomId(), command.content()); + + // 4. Record 업데이트 + recordCommandPort.update(record); + return command.roomId(); + } + + @Override + public Long updateVote(VoteUpdateCommand command) { + // 1. 사용자가 방의 참가자인지 검증 + roomParticipantValidator.validateUserIsRoomMember(command.roomId(), command.userId()); + + // 2. Vote 조회 + Vote vote = voteCommandPort.getByIdOrThrow(command.postId()); + + // 3. Vote 수정 + vote.updateVote(command.userId(), command.roomId(), command.content()); + + // 4. Vote 업데이트 + voteCommandPort.updateVote(vote); + return command.roomId(); + } +} diff --git a/src/main/java/konkuk/thip/roompost/domain/Record.java b/src/main/java/konkuk/thip/roompost/domain/Record.java index 003628218..f62c314ac 100644 --- a/src/main/java/konkuk/thip/roompost/domain/Record.java +++ b/src/main/java/konkuk/thip/roompost/domain/Record.java @@ -101,6 +101,12 @@ private void validateCreator(Long userId) { } } + public void updateRecord(Long userId, Long roomId, String content) { + validateRoomId(roomId); + validateCreator(userId); + this.content = content; + } + public void validateDeletable(Long userId,Long roomId) { validateRoomId(roomId); validateCreator(userId); diff --git a/src/main/java/konkuk/thip/roompost/domain/Vote.java b/src/main/java/konkuk/thip/roompost/domain/Vote.java index 31b7c0106..3f3111193 100644 --- a/src/main/java/konkuk/thip/roompost/domain/Vote.java +++ b/src/main/java/konkuk/thip/roompost/domain/Vote.java @@ -95,6 +95,12 @@ private void validateCreator(Long userId) { } } + public void updateVote(Long userId, Long roomId, String content) { + validateRoomId(roomId); + validateCreator(userId); + this.content = content; + } + public void validateDeletable(Long userId,Long roomId) { validateRoomId(roomId); validateCreator(userId); @@ -105,4 +111,5 @@ private void validateRoomId(Long roomId) { throw new InvalidStateException(VOTE_ACCESS_FORBIDDEN, new IllegalArgumentException("투표가 해당 방에 속하지 않습니다.")); } } + } diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordUpdateApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordUpdateApiTest.java new file mode 100644 index 000000000..eeea8ad1c --- /dev/null +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/RecordUpdateApiTest.java @@ -0,0 +1,233 @@ +package konkuk.thip.roompost.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.roompost.adapter.out.jpa.RecordJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 기록 수정 api 통합 테스트") +class RecordUpdateApiTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @Autowired UserJpaRepository userJpaRepository; + @Autowired BookJpaRepository bookJpaRepository; + @Autowired RoomJpaRepository roomJpaRepository; + @Autowired RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired RecordJpaRepository recordJpaRepository; + + private UserJpaEntity author; // 기록 작성자(방 참가자) + private UserJpaEntity otherMember; // 작성자가 아닌 다른 참가자 + private UserJpaEntity outsider; // 방 참가자가 아닌 사용자 + private RoomJpaEntity room; + private RecordJpaEntity record; + + @BeforeEach + void setUp() { + // 1) 사용자 3명 + author = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "작성자")); + otherMember = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "다른참가자")); + outsider = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "외부인")); + + // 2) 도서/방 + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + // 3) 방 참가자(작성자, 다른참가자만 방에 소속) + roomParticipantJpaRepository.save( + RoomParticipantJpaEntity.builder() + .currentPage(10) + .userPercentage(80.0) + .roomParticipantRole(RoomParticipantRole.HOST) + .userJpaEntity(author) + .roomJpaEntity(room) + .build() + ); + roomParticipantJpaRepository.save( + RoomParticipantJpaEntity.builder() + .currentPage(5) + .userPercentage(50.0) + .roomParticipantRole(RoomParticipantRole.MEMBER) + .userJpaEntity(otherMember) + .roomJpaEntity(room) + .build() + ); + + // 4) 기존 기록(작성자가 생성한 기록) + record = recordJpaRepository.save(TestEntityFactory.createRecord(author, room)); + } + + @Test + @DisplayName("[성공] 작성자이자 방 참가자가 내용을 수정하면 200 OK, DB 반영") + void update_record_success() throws Exception { + // given + String newContent = "수정된 기록 내용"; + Map body = Map.of("content", newContent); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), record.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()); + + // DB 반영 확인 + RecordJpaEntity updated = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(updated.getContent()).isEqualTo(newContent); + } + + @Test + @DisplayName("[실패-검증] content가 공백이면 400 Bad Request") + void update_record_validation_blank() throws Exception { + // given + Map body = Map.of("content", ""); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), record.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message").exists()); // 상세 문구는 구현체 메시지에 의존 + } + + @Test + @DisplayName("[실패-검증] content가 500자 초과면 400 Bad Request") + void update_record_validation_too_long() throws Exception { + // given + String tooLong = "가".repeat(501); + Map body = Map.of("content", tooLong); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), record.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("[실패-권한] 방 참가자가 아니면 403 Forbidden, DB 불변") + void update_record_forbidden_not_room_member() throws Exception { + // given + String prev = record.getContent(); + Map body = Map.of("content", "외부인이 수정 시도"); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), record.getPostId()) + .requestAttr("userId", outsider.getUserId()) // 참가자 아님 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + // DB 불변 확인 + RecordJpaEntity after = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + + @Test + @DisplayName("[실패-권한] 작성자가 아니면 403 Forbidden, DB 불변") + void update_record_forbidden_not_creator() throws Exception { + // given + String prev = record.getContent(); + Map body = Map.of("content", "작성자가 아닌 회원이 수정 시도"); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), record.getPostId()) + .requestAttr("userId", otherMember.getUserId()) // 참가자지만 작성자 아님 + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + // DB 불변 확인 + RecordJpaEntity after = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + + @Test + @DisplayName("[실패-존재X] 없는 recordId면 404 Not Found") + void update_record_not_found() throws Exception { + // given + Map body = Map.of("content", "아무 내용"); + Long notExistId = Long.MAX_VALUE; + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", room.getRoomId(), notExistId) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").exists()); + } + + @Nested + @DisplayName("[실패-무결성] 잘못된 roomId로 요청 시 접근 거부(해당 방 소속 기록이 아님)") + class WrongRoomIdCases { + + private RoomJpaEntity otherRoom; + + @BeforeEach + void setUpOtherRoom() { + BookJpaEntity book2 = bookJpaRepository.save(TestEntityFactory.createBook()); + otherRoom = roomJpaRepository.save(TestEntityFactory.createRoom(book2, TestEntityFactory.createScienceCategory())); + // author는 otherRoom 참가자가 아님(또는 참가자라도 기록은 원래 room 소속) + } + + @Test + @DisplayName("roomId 불일치 → 403 Forbidden") + void update_record_wrong_room_id() throws Exception { + Map body = Map.of("content", "방 불일치 수정 시도"); + String prev = record.getContent(); + + mockMvc.perform(patch("/rooms/{roomId}/records/{recordId}", otherRoom.getRoomId(), record.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + RecordJpaEntity after = recordJpaRepository.findById(record.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/roompost/adapter/in/web/VoteUpdateApiTest.java b/src/test/java/konkuk/thip/roompost/adapter/in/web/VoteUpdateApiTest.java new file mode 100644 index 000000000..4c287d1f9 --- /dev/null +++ b/src/test/java/konkuk/thip/roompost/adapter/in/web/VoteUpdateApiTest.java @@ -0,0 +1,220 @@ +package konkuk.thip.roompost.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.roompost.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.roompost.adapter.out.persistence.repository.vote.VoteJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 투표 수정 api 통합 테스트") +class VoteUpdateApiTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper objectMapper; + + @Autowired UserJpaRepository userJpaRepository; + @Autowired BookJpaRepository bookJpaRepository; + @Autowired RoomJpaRepository roomJpaRepository; + @Autowired RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired VoteJpaRepository voteJpaRepository; + + private UserJpaEntity author; // 투표 작성자(방 참가자) + private UserJpaEntity otherMember; // 작성자가 아닌 다른 참가자 + private UserJpaEntity outsider; // 방 참가자가 아닌 사용자 + private RoomJpaEntity room; + private VoteJpaEntity vote; + + @BeforeEach + void setUp() { + // 1) 사용자 3명 + author = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "작성자")); + otherMember = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "다른참가자")); + outsider = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER, "외부인")); + + // 2) 도서/방 + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBook()); + Category category = TestEntityFactory.createLiteratureCategory(); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + // 3) 방 참가자 구성(작성자, 다른참가자만 소속) + roomParticipantJpaRepository.save( + RoomParticipantJpaEntity.builder() + .currentPage(10) + .userPercentage(80.0) + .roomParticipantRole(RoomParticipantRole.HOST) + .userJpaEntity(author) + .roomJpaEntity(room) + .build() + ); + roomParticipantJpaRepository.save( + RoomParticipantJpaEntity.builder() + .currentPage(5) + .userPercentage(50.0) + .roomParticipantRole(RoomParticipantRole.MEMBER) + .userJpaEntity(otherMember) + .roomJpaEntity(room) + .build() + ); + + // 4) 기존 투표(작성자가 생성) + vote = voteJpaRepository.save(TestEntityFactory.createVote(author, room)); + } + + @Test + @DisplayName("[성공] 작성자이자 방 참가자가 내용을 수정하면 200 OK, DB 반영") + void update_vote_success() throws Exception { + // given + String newContent = "수정된 투표 내용"; + Map body = Map.of("content", newContent); + + // when & then + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), vote.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()); + + // DB 반영 확인 + VoteJpaEntity updated = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(updated.getContent()).isEqualTo(newContent); + } + + @Test + @DisplayName("[실패-검증] content가 공백이면 400 Bad Request") + void update_vote_validation_blank() throws Exception { + Map body = Map.of("content", ""); + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), vote.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("[실패-검증] content가 20자 초과면 400 Bad Request") + void update_vote_validation_too_long() throws Exception { + String tooLong = "가".repeat(21); + Map body = Map.of("content", tooLong); + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), vote.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("[실패-권한] 방 참가자가 아니면 403 Forbidden, DB 불변") + void update_vote_forbidden_not_room_member() throws Exception { + String prev = vote.getContent(); + Map body = Map.of("content", "외부인이 수정 시도"); + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), vote.getPostId()) + .requestAttr("userId", outsider.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + VoteJpaEntity after = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + + @Test + @DisplayName("[실패-권한] 작성자가 아니면 403 Forbidden, DB 불변") + void update_vote_forbidden_not_creator() throws Exception { + String prev = vote.getContent(); + Map body = Map.of("content", "작성자가 아닌 회원이 수정 시도"); + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), vote.getPostId()) + .requestAttr("userId", otherMember.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + VoteJpaEntity after = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + + @Test + @DisplayName("[실패-존재X] 없는 voteId면 404 Not Found") + void update_vote_not_found() throws Exception { + Map body = Map.of("content", "아무 내용"); + Long notExistId = Long.MAX_VALUE; + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", room.getRoomId(), notExistId) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.message").exists()); + } + + @Nested + @DisplayName("[실패-무결성] 잘못된 roomId로 요청 시 접근 거부(해당 방 소속 투표가 아님)") + class WrongRoomIdCases { + + private RoomJpaEntity otherRoom; + + @BeforeEach + void setUpOtherRoom() { + BookJpaEntity book2 = bookJpaRepository.save(TestEntityFactory.createBook()); + otherRoom = roomJpaRepository.save(TestEntityFactory.createRoom(book2, TestEntityFactory.createScienceCategory())); + } + + @Test + @DisplayName("roomId 불일치 → 403 Forbidden") + void update_vote_wrong_room_id() throws Exception { + Map body = Map.of("content", "방 불일치 수정 시도"); + String prev = vote.getContent(); + + mockMvc.perform(patch("/rooms/{roomId}/votes/{voteId}", otherRoom.getRoomId(), vote.getPostId()) + .requestAttr("userId", author.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.message").exists()); + + VoteJpaEntity after = voteJpaRepository.findById(vote.getPostId()).orElseThrow(); + assertThat(after.getContent()).isEqualTo(prev); + } + } +} \ No newline at end of file