diff --git a/src/main/java/com/debatetimer/config/ErrorDecoderConfig.java b/src/main/java/com/debatetimer/config/ErrorDecoderConfig.java new file mode 100644 index 00000000..5fee2c04 --- /dev/null +++ b/src/main/java/com/debatetimer/config/ErrorDecoderConfig.java @@ -0,0 +1,33 @@ +package com.debatetimer.config; + +import com.debatetimer.exception.decoder.H2ErrorDecoder; +import com.debatetimer.exception.decoder.MySqlErrorDecoder; +import com.debatetimer.exception.decoder.RepositoryErrorDecoder; +import lombok.NoArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class ErrorDecoderConfig { + + @Profile({"dev", "prod"}) + @Configuration + public static class MySqlErrorDecoderConfig { + + @Bean + public RepositoryErrorDecoder mySqlErrorDecoder() { + return new MySqlErrorDecoder(); + } + } + + @Profile({"test", "local"}) + @Configuration + public static class H2ErrorDecoderConfig { + + @Bean + public RepositoryErrorDecoder h2ErrorDecoder() { + return new H2ErrorDecoder(); + } + } +} diff --git a/src/main/java/com/debatetimer/controller/poll/VoteController.java b/src/main/java/com/debatetimer/controller/poll/VoteController.java new file mode 100644 index 00000000..64f53365 --- /dev/null +++ b/src/main/java/com/debatetimer/controller/poll/VoteController.java @@ -0,0 +1,37 @@ +package com.debatetimer.controller.poll; + +import com.debatetimer.dto.poll.request.VoteRequest; +import com.debatetimer.dto.poll.response.VoteCreateResponse; +import com.debatetimer.dto.poll.response.VoterPollInfoResponse; +import com.debatetimer.service.poll.VoteService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class VoteController { + + private final VoteService voteService; + + @GetMapping("/api/polls/{pollId}/votes") + @ResponseStatus(HttpStatus.OK) + public VoterPollInfoResponse getVotersPollInfo(@PathVariable long pollId) { + return voteService.getVoterPollInfo(pollId); + } + + @PostMapping("/api/polls/{pollId}/votes") + @ResponseStatus(HttpStatus.CREATED) + public VoteCreateResponse votePoll( + @PathVariable long pollId, + @RequestBody @Valid VoteRequest voteRequest + ) { + return voteService.vote(pollId, voteRequest); + } +} diff --git a/src/main/java/com/debatetimer/domain/member/Member.java b/src/main/java/com/debatetimer/domain/member/Member.java index 9dec2246..923d17c5 100644 --- a/src/main/java/com/debatetimer/domain/member/Member.java +++ b/src/main/java/com/debatetimer/domain/member/Member.java @@ -1,6 +1,6 @@ package com.debatetimer.domain.member; -import com.debatetimer.entity.customize.BaseTimeEntity; +import com.debatetimer.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/com/debatetimer/domain/poll/Poll.java b/src/main/java/com/debatetimer/domain/poll/Poll.java index fd1c15a5..b3b23663 100644 --- a/src/main/java/com/debatetimer/domain/poll/Poll.java +++ b/src/main/java/com/debatetimer/domain/poll/Poll.java @@ -26,4 +26,8 @@ public Poll(Long id, long tableId, long memberId, PollStatus status, String prosTeamName, String consTeamName, String agenda) { this(id, tableId, memberId, status, new TeamName(prosTeamName), new TeamName(consTeamName), new Agenda(agenda)); } + + public boolean isProgress() { + return status.isProgress(); + } } diff --git a/src/main/java/com/debatetimer/domain/poll/PollStatus.java b/src/main/java/com/debatetimer/domain/poll/PollStatus.java index f2d0ebad..98379b68 100644 --- a/src/main/java/com/debatetimer/domain/poll/PollStatus.java +++ b/src/main/java/com/debatetimer/domain/poll/PollStatus.java @@ -5,4 +5,8 @@ public enum PollStatus { PROGRESS, DONE, ; + + public boolean isProgress() { + return this == PROGRESS; + } } diff --git a/src/main/java/com/debatetimer/domain/poll/Vote.java b/src/main/java/com/debatetimer/domain/poll/Vote.java index a69f946a..6d14cf5f 100644 --- a/src/main/java/com/debatetimer/domain/poll/Vote.java +++ b/src/main/java/com/debatetimer/domain/poll/Vote.java @@ -13,6 +13,10 @@ public class Vote { private final ParticipantName name; private final ParticipateCode code; + public Vote(long pollId, VoteTeam team, String name, String code) { + this(null, pollId, team, name, code); + } + public Vote(Long id, long pollId, VoteTeam team, String name, String code) { this(id, pollId, team, new ParticipantName(name), new ParticipateCode(code)); } diff --git a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java index fa46adfb..e35ceca4 100644 --- a/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/poll/PollDomainRepository.java @@ -2,8 +2,6 @@ import com.debatetimer.domain.poll.Poll; import com.debatetimer.entity.poll.PollEntity; -import com.debatetimer.exception.custom.DTClientErrorException; -import com.debatetimer.exception.errorcode.ClientErrorCode; import com.debatetimer.repository.poll.PollRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -24,19 +22,20 @@ public Poll create(Poll poll) { @Transactional(readOnly = true) public Poll getByIdAndMemberId(long id, long memberId) { - return findPoll(id, memberId) + return pollRepository.getByIdAndMemberId(id, memberId) + .toDomain(); + } + + @Transactional(readOnly = true) + public Poll getById(long id) { + return pollRepository.getById(id) .toDomain(); } @Transactional public Poll finishPoll(long pollId, long memberId) { - PollEntity pollEntity = findPoll(pollId, memberId); + PollEntity pollEntity = pollRepository.getByIdAndMemberId(pollId, memberId); pollEntity.updateToDone(); return pollEntity.toDomain(); } - - private PollEntity findPoll(long pollId, long memberId) { - return pollRepository.findByIdAndMemberId(pollId, memberId) - .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.POLL_NOT_FOUND)); - } } diff --git a/src/main/java/com/debatetimer/domainrepository/poll/VoteDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/poll/VoteDomainRepository.java index 97f8dd1f..dc79f01f 100644 --- a/src/main/java/com/debatetimer/domainrepository/poll/VoteDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/poll/VoteDomainRepository.java @@ -1,20 +1,30 @@ package com.debatetimer.domainrepository.poll; +import com.debatetimer.domain.poll.ParticipateCode; +import com.debatetimer.domain.poll.Vote; import com.debatetimer.domain.poll.VoteInfo; import com.debatetimer.domain.poll.VoteTeam; +import com.debatetimer.entity.poll.PollEntity; import com.debatetimer.entity.poll.VoteEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.decoder.RepositoryErrorDecoder; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.poll.PollRepository; import com.debatetimer.repository.poll.VoteRepository; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Repository; @Repository @RequiredArgsConstructor public class VoteDomainRepository { + private final PollRepository pollRepository; private final VoteRepository voteRepository; + private final RepositoryErrorDecoder errorDecoder; public VoteInfo findVoteInfoByPollId(long pollId) { List pollVotes = voteRepository.findAllByPollId(pollId); @@ -28,4 +38,22 @@ private VoteInfo countVotes(long pollId, List voteEntities) { long consCount = teamCount.getOrDefault(VoteTeam.CONS, 0L); return new VoteInfo(pollId, prosCount, consCount); } + + public boolean isExists(long pollId, ParticipateCode code) { + return voteRepository.existsByPollIdAndParticipateCode(pollId, code.getValue()); + } + + public Vote save(Vote vote) { + try { + PollEntity pollEntity = pollRepository.getById(vote.getPollId()); + VoteEntity voteEntity = new VoteEntity(vote, pollEntity); + return voteRepository.save(voteEntity) + .toDomain(); + } catch (DataIntegrityViolationException exception) { + if (errorDecoder.isUniqueConstraintViolation(exception)) { + throw new DTClientErrorException(ClientErrorCode.ALREADY_VOTED_PARTICIPANT); + } + throw exception; + } + } } diff --git a/src/main/java/com/debatetimer/domainrepository/poll/CustomizeTableDomainRepository.java b/src/main/java/com/debatetimer/domainrepository/table/CustomizeTableDomainRepository.java similarity index 95% rename from src/main/java/com/debatetimer/domainrepository/poll/CustomizeTableDomainRepository.java rename to src/main/java/com/debatetimer/domainrepository/table/CustomizeTableDomainRepository.java index 850c53bd..f48ffc33 100644 --- a/src/main/java/com/debatetimer/domainrepository/poll/CustomizeTableDomainRepository.java +++ b/src/main/java/com/debatetimer/domainrepository/table/CustomizeTableDomainRepository.java @@ -1,4 +1,4 @@ -package com.debatetimer.domainrepository.poll; +package com.debatetimer.domainrepository.table; import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.member.Member; diff --git a/src/main/java/com/debatetimer/dto/poll/request/VoteRequest.java b/src/main/java/com/debatetimer/dto/poll/request/VoteRequest.java new file mode 100644 index 00000000..ccf22242 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/poll/request/VoteRequest.java @@ -0,0 +1,13 @@ +package com.debatetimer.dto.poll.request; + +import com.debatetimer.domain.poll.VoteTeam; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record VoteRequest( + @NotBlank String name, + @NotBlank String participateCode, + @NotNull VoteTeam team +) { + +} diff --git a/src/main/java/com/debatetimer/dto/poll/response/VoteCreateResponse.java b/src/main/java/com/debatetimer/dto/poll/response/VoteCreateResponse.java new file mode 100644 index 00000000..07bf2f2a --- /dev/null +++ b/src/main/java/com/debatetimer/dto/poll/response/VoteCreateResponse.java @@ -0,0 +1,16 @@ +package com.debatetimer.dto.poll.response; + +import com.debatetimer.domain.poll.Vote; +import com.debatetimer.domain.poll.VoteTeam; + +public record VoteCreateResponse( + long id, + String name, + String participateCode, + VoteTeam team +) { + + public VoteCreateResponse(Vote vote) { + this(vote.getId(), vote.getName().getValue(), vote.getCode().getValue(), vote.getTeam()); + } +} diff --git a/src/main/java/com/debatetimer/dto/poll/response/VoterPollInfoResponse.java b/src/main/java/com/debatetimer/dto/poll/response/VoterPollInfoResponse.java new file mode 100644 index 00000000..acf86030 --- /dev/null +++ b/src/main/java/com/debatetimer/dto/poll/response/VoterPollInfoResponse.java @@ -0,0 +1,31 @@ +package com.debatetimer.dto.poll.response; + +import com.debatetimer.domain.poll.ParticipateCode; +import com.debatetimer.domain.poll.Poll; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domain.poll.VoteInfo; + +public record VoterPollInfoResponse( + long id, + PollStatus status, + String prosTeamName, + String consTeamName, + String participateCode, + long totalCount, + long prosCount, + long consCount +) { + + public VoterPollInfoResponse(Poll poll, VoteInfo voteInfo, ParticipateCode code) { + this( + poll.getId(), + poll.getStatus(), + poll.getProsTeamName().getValue(), + poll.getConsTeamName().getValue(), + code.getValue(), + voteInfo.getTotalCount(), + voteInfo.getProsCount(), + voteInfo.getConsCount() + ); + } +} diff --git a/src/main/java/com/debatetimer/entity/customize/BaseTimeEntity.java b/src/main/java/com/debatetimer/entity/BaseTimeEntity.java similarity index 93% rename from src/main/java/com/debatetimer/entity/customize/BaseTimeEntity.java rename to src/main/java/com/debatetimer/entity/BaseTimeEntity.java index 1bca8c75..e9405201 100644 --- a/src/main/java/com/debatetimer/entity/customize/BaseTimeEntity.java +++ b/src/main/java/com/debatetimer/entity/BaseTimeEntity.java @@ -1,4 +1,4 @@ -package com.debatetimer.entity.customize; +package com.debatetimer.entity; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; diff --git a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java index 88cfd3bb..ff24334e 100644 --- a/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java +++ b/src/main/java/com/debatetimer/entity/customize/CustomizeTableEntity.java @@ -3,6 +3,7 @@ import com.debatetimer.domain.customize.CustomizeTable; import com.debatetimer.domain.member.Member; import com.debatetimer.dto.member.TableType; +import com.debatetimer.entity.BaseTimeEntity; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -22,7 +23,7 @@ @Getter @Table(name = "customize_table") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class CustomizeTableEntity { +public class CustomizeTableEntity extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/debatetimer/entity/poll/PollEntity.java b/src/main/java/com/debatetimer/entity/poll/PollEntity.java index bb4e5433..04e8ac28 100644 --- a/src/main/java/com/debatetimer/entity/poll/PollEntity.java +++ b/src/main/java/com/debatetimer/entity/poll/PollEntity.java @@ -2,7 +2,7 @@ import com.debatetimer.domain.poll.Poll; import com.debatetimer.domain.poll.PollStatus; -import com.debatetimer.entity.customize.BaseTimeEntity; +import com.debatetimer.entity.BaseTimeEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/com/debatetimer/entity/poll/VoteEntity.java b/src/main/java/com/debatetimer/entity/poll/VoteEntity.java index 47dd0a2c..0b0f26c7 100644 --- a/src/main/java/com/debatetimer/entity/poll/VoteEntity.java +++ b/src/main/java/com/debatetimer/entity/poll/VoteEntity.java @@ -2,6 +2,7 @@ import com.debatetimer.domain.poll.Vote; import com.debatetimer.domain.poll.VoteTeam; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -12,6 +13,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.AccessLevel; @@ -21,7 +23,9 @@ @Entity @Getter -@Table(name = "vote") +@Table(name = "vote", uniqueConstraints = { + @UniqueConstraint(columnNames = {"poll_id", "participate_code"}) +}) @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor public class VoteEntity { @@ -43,9 +47,14 @@ public class VoteEntity { private String name; @NotBlank - private String participantCode; + @Column(name = "participate_code") + private String participateCode; + + public VoteEntity(Vote vote, PollEntity pollEntity) { + this(vote.getId(), pollEntity, vote.getTeam(), vote.getName().getValue(), vote.getCode().getValue()); + } public Vote toDomain() { - return new Vote(id, poll.getId(), team, name, participantCode); + return new Vote(id, poll.getId(), team, name, participateCode); } } diff --git a/src/main/java/com/debatetimer/exception/decoder/H2ErrorDecoder.java b/src/main/java/com/debatetimer/exception/decoder/H2ErrorDecoder.java new file mode 100644 index 00000000..42ea2660 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/decoder/H2ErrorDecoder.java @@ -0,0 +1,22 @@ +package com.debatetimer.exception.decoder; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; + +public class H2ErrorDecoder implements RepositoryErrorDecoder { + + protected static final String UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE = "23505"; + + @Override + public boolean isUniqueConstraintViolation(DataIntegrityViolationException e) { + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof ConstraintViolationException cve) { + String sqlState = cve.getSQLException().getSQLState(); + return UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE.equals(sqlState); + } + cause = cause.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/debatetimer/exception/decoder/MySqlErrorDecoder.java b/src/main/java/com/debatetimer/exception/decoder/MySqlErrorDecoder.java new file mode 100644 index 00000000..f7ddf38c --- /dev/null +++ b/src/main/java/com/debatetimer/exception/decoder/MySqlErrorDecoder.java @@ -0,0 +1,27 @@ +package com.debatetimer.exception.decoder; + +import java.sql.SQLException; +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; + +public class MySqlErrorDecoder implements RepositoryErrorDecoder { + + protected static final String MYSQL_UNIQUE_VIOLATION = "23000"; + protected static final int MYSQL_DUP_ERROR_CODE = 1062; + + @Override + public boolean isUniqueConstraintViolation(DataIntegrityViolationException e) { + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof ConstraintViolationException cve) { + SQLException sqlEx = cve.getSQLException(); + String sqlState = sqlEx.getSQLState(); + int errorCode = sqlEx.getErrorCode(); + return MYSQL_UNIQUE_VIOLATION.equals(sqlState) + && MYSQL_DUP_ERROR_CODE == errorCode; + } + cause = cause.getCause(); + } + return false; + } +} diff --git a/src/main/java/com/debatetimer/exception/decoder/RepositoryErrorDecoder.java b/src/main/java/com/debatetimer/exception/decoder/RepositoryErrorDecoder.java new file mode 100644 index 00000000..8d71e834 --- /dev/null +++ b/src/main/java/com/debatetimer/exception/decoder/RepositoryErrorDecoder.java @@ -0,0 +1,8 @@ +package com.debatetimer.exception.decoder; + +import org.springframework.dao.DataIntegrityViolationException; + +public interface RepositoryErrorDecoder { + + boolean isUniqueConstraintViolation(DataIntegrityViolationException exception); +} diff --git a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java index f0672c70..695cbb0c 100644 --- a/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java +++ b/src/main/java/com/debatetimer/exception/errorcode/ClientErrorCode.java @@ -50,6 +50,8 @@ public enum ClientErrorCode implements ResponseErrorCode { INVALID_POLL_PARTICIPANT_NAME(HttpStatus.BAD_REQUEST, "잘못된 투표자 이름입니다"), INVALID_POLL_PARTICIPANT_CODE(HttpStatus.BAD_REQUEST, "잘못된 투표참여 코드입니다"), + ALREADY_DONE_POLL(HttpStatus.BAD_REQUEST, "이미 완료된 투표 입니다"), + ALREADY_VOTED_PARTICIPANT(HttpStatus.BAD_REQUEST, "이미 참여한 투표자 입니다"), TABLE_NOT_FOUND(HttpStatus.NOT_FOUND, "토론 테이블을 찾을 수 없습니다."), NOT_TABLE_OWNER(HttpStatus.UNAUTHORIZED, "테이블을 소유한 회원이 아닙니다."), diff --git a/src/main/java/com/debatetimer/repository/poll/PollRepository.java b/src/main/java/com/debatetimer/repository/poll/PollRepository.java index 121d1627..22c6db56 100644 --- a/src/main/java/com/debatetimer/repository/poll/PollRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/PollRepository.java @@ -1,10 +1,22 @@ package com.debatetimer.repository.poll; import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface PollRepository extends JpaRepository { Optional findByIdAndMemberId(long id, long memberId); + + default PollEntity getById(long id) { + return findById(id) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.POLL_NOT_FOUND)); + } + + default PollEntity getByIdAndMemberId(long id, long memberId) { + return findByIdAndMemberId(id, memberId) + .orElseThrow(() -> new DTClientErrorException(ClientErrorCode.POLL_NOT_FOUND)); + } } diff --git a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java index a9709200..5f211efd 100644 --- a/src/main/java/com/debatetimer/repository/poll/VoteRepository.java +++ b/src/main/java/com/debatetimer/repository/poll/VoteRepository.java @@ -7,4 +7,6 @@ public interface VoteRepository extends JpaRepository { List findAllByPollId(long pollId); + + boolean existsByPollIdAndParticipateCode(long pollId, String participateCode); } diff --git a/src/main/java/com/debatetimer/service/poll/PollService.java b/src/main/java/com/debatetimer/service/poll/PollService.java index a6409fdb..7d21bffb 100644 --- a/src/main/java/com/debatetimer/service/poll/PollService.java +++ b/src/main/java/com/debatetimer/service/poll/PollService.java @@ -4,9 +4,9 @@ import com.debatetimer.domain.member.Member; import com.debatetimer.domain.poll.Poll; import com.debatetimer.domain.poll.VoteInfo; -import com.debatetimer.domainrepository.poll.CustomizeTableDomainRepository; import com.debatetimer.domainrepository.poll.PollDomainRepository; import com.debatetimer.domainrepository.poll.VoteDomainRepository; +import com.debatetimer.domainrepository.table.CustomizeTableDomainRepository; import com.debatetimer.dto.poll.response.PollCreateResponse; import com.debatetimer.dto.poll.response.PollInfoResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/debatetimer/service/poll/VoteService.java b/src/main/java/com/debatetimer/service/poll/VoteService.java new file mode 100644 index 00000000..8c485907 --- /dev/null +++ b/src/main/java/com/debatetimer/service/poll/VoteService.java @@ -0,0 +1,56 @@ +package com.debatetimer.service.poll; + +import com.debatetimer.domain.poll.ParticipateCode; +import com.debatetimer.domain.poll.Poll; +import com.debatetimer.domain.poll.Vote; +import com.debatetimer.domain.poll.VoteInfo; +import com.debatetimer.domainrepository.poll.PollDomainRepository; +import com.debatetimer.domainrepository.poll.VoteDomainRepository; +import com.debatetimer.dto.poll.request.VoteRequest; +import com.debatetimer.dto.poll.response.VoteCreateResponse; +import com.debatetimer.dto.poll.response.VoterPollInfoResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class VoteService { + + private final VoteDomainRepository voteDomainRepository; + private final PollDomainRepository pollDomainRepository; + + @Transactional + public VoteCreateResponse vote(long pollId, VoteRequest voteRequest) { + validateProgressPoll(pollId); + validateAlreadyVoted(pollId, voteRequest.participateCode()); + Vote vote = new Vote(pollId, voteRequest.team(), voteRequest.name(), voteRequest.participateCode()); + Vote savedVote = voteDomainRepository.save(vote); + return new VoteCreateResponse(savedVote); + } + + private void validateProgressPoll(long pollId) { + Poll poll = pollDomainRepository.getById(pollId); + if (!poll.isProgress()) { + throw new DTClientErrorException(ClientErrorCode.ALREADY_DONE_POLL); + } + } + + private void validateAlreadyVoted(long pollId, String participateCode) { + ParticipateCode code = new ParticipateCode(participateCode); + if (voteDomainRepository.isExists(pollId, code)) { + throw new DTClientErrorException(ClientErrorCode.ALREADY_VOTED_PARTICIPANT); + } + } + + @Transactional(readOnly = true) + public VoterPollInfoResponse getVoterPollInfo(long pollId) { + Poll poll = pollDomainRepository.getById(pollId); + VoteInfo voteInfo = voteDomainRepository.findVoteInfoByPollId(pollId); + ParticipateCode code = new ParticipateCode(UUID.randomUUID().toString()); + return new VoterPollInfoResponse(poll, voteInfo, code); + } +} diff --git a/src/main/resources/db/migration/V12__add_participate_code_unique_constraint.sql b/src/main/resources/db/migration/V12__add_participate_code_unique_constraint.sql new file mode 100644 index 00000000..315fd427 --- /dev/null +++ b/src/main/resources/db/migration/V12__add_participate_code_unique_constraint.sql @@ -0,0 +1,5 @@ +ALTER TABLE vote + CHANGE participant_code participate_code VARCHAR (255) NOT NULL; + +ALTER TABLE vote + ADD CONSTRAINT uq_vote_poll_participate UNIQUE (poll_id, participate_code); diff --git a/src/test/java/com/debatetimer/controller/BaseControllerTest.java b/src/test/java/com/debatetimer/controller/BaseControllerTest.java index 83d35dda..c63a61fb 100644 --- a/src/test/java/com/debatetimer/controller/BaseControllerTest.java +++ b/src/test/java/com/debatetimer/controller/BaseControllerTest.java @@ -4,10 +4,12 @@ import com.debatetimer.client.oauth.OAuthClient; import com.debatetimer.domain.customize.CustomizeBoxType; import com.debatetimer.domain.customize.Stance; +import com.debatetimer.domain.poll.VoteTeam; import com.debatetimer.dto.customize.request.BellRequest; import com.debatetimer.dto.customize.request.CustomizeTableCreateRequest; import com.debatetimer.dto.customize.request.CustomizeTableInfoCreateRequest; import com.debatetimer.dto.customize.request.CustomizeTimeBoxCreateRequest; +import com.debatetimer.dto.poll.request.VoteRequest; import com.debatetimer.fixture.CustomizeTableGenerator; import com.debatetimer.fixture.CustomizeTimeBoxGenerator; import com.debatetimer.fixture.HeaderGenerator; @@ -24,6 +26,7 @@ import io.restassured.filter.log.RequestLoggingFilter; import io.restassured.filter.log.ResponseLoggingFilter; import io.restassured.specification.RequestSpecification; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -90,6 +93,13 @@ protected ArbitraryBuilder getCustomizeTableCreateR .set("table", getCustomizeTimeBoxCreateRequestBuilder().sampleList(2)); } + protected ArbitraryBuilder getVoteRequestBuilder() { + return fixtureMonkey.giveMeBuilder(VoteRequest.class) + .set("name", "콜리") + .set("team", VoteTeam.PROS) + .set("participateCode", UUID.randomUUID().toString()); + } + private ArbitraryBuilder getCustomizeTableInfoCreateRequestBuilder() { return fixtureMonkey.giveMeBuilder(CustomizeTableInfoCreateRequest.class) .set("name", "자유 테이블") diff --git a/src/test/java/com/debatetimer/controller/BaseDocumentTest.java b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java index 40c80cc1..0a74a3cc 100644 --- a/src/test/java/com/debatetimer/controller/BaseDocumentTest.java +++ b/src/test/java/com/debatetimer/controller/BaseDocumentTest.java @@ -13,6 +13,7 @@ import com.debatetimer.service.customize.CustomizeService; import com.debatetimer.service.member.MemberService; import com.debatetimer.service.poll.PollService; +import com.debatetimer.service.poll.VoteService; import io.restassured.RestAssured; import io.restassured.builder.RequestSpecBuilder; import io.restassured.filter.log.RequestLoggingFilter; @@ -66,6 +67,9 @@ public abstract class BaseDocumentTest { @MockitoBean protected PollService pollService; + @MockitoBean + protected VoteService voteService; + @MockitoBean protected AuthManager authManager; diff --git a/src/test/java/com/debatetimer/controller/poll/VoteControllerTest.java b/src/test/java/com/debatetimer/controller/poll/VoteControllerTest.java new file mode 100644 index 00000000..06276c5b --- /dev/null +++ b/src/test/java/com/debatetimer/controller/poll/VoteControllerTest.java @@ -0,0 +1,173 @@ +package com.debatetimer.controller.poll; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.controller.BaseControllerTest; +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domain.poll.VoteTeam; +import com.debatetimer.dto.poll.request.VoteRequest; +import com.debatetimer.dto.poll.response.VoteCreateResponse; +import com.debatetimer.dto.poll.response.VoterPollInfoResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.fixture.NullAndEmptyAndBlankSource; +import io.restassured.http.ContentType; +import java.util.UUID; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.springframework.http.HttpStatus; + +class VoteControllerTest extends BaseControllerTest { + + @Nested + class GetVotersPollInfo { + + @Test + void 투표자가_선거정보를_조회할_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "비토"); + voteGenerator.generate(pollEntity, VoteTeam.CONS, "커찬"); + + VoterPollInfoResponse response = given() + .contentType(ContentType.JSON) + .pathParam("pollId", pollEntity.getId()) + .when().get("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.OK.value()) + .extract().as(VoterPollInfoResponse.class); + + assertAll( + () -> assertThat(response.id()).isEqualTo(pollEntity.getId()), + () -> assertThat(response.prosTeamName()).isEqualTo(pollEntity.getProsTeamName()), + () -> assertThat(response.consTeamName()).isEqualTo(pollEntity.getConsTeamName()), + () -> assertThat(response.status()).isEqualTo(pollEntity.getStatus()), + () -> assertThat(response.totalCount()).isEqualTo(3L), + () -> assertThat(response.participateCode()).isNotBlank(), + () -> assertThat(response.prosCount()).isEqualTo(2L), + () -> assertThat(response.consCount()).isEqualTo(1L) + ); + } + } + + @Nested + class VotePoll { + + @Test + void 진행_중인_선거에_최초로_투표_할_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + VoteRequest voteRequest = getVoteRequestBuilder().sample(); + + VoteCreateResponse response = given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", pollEntity.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.CREATED.value()) + .extract().as(VoteCreateResponse.class); + + assertAll( + () -> assertThat(response.name()).isEqualTo(voteRequest.name()), + () -> assertThat(response.participateCode()).isEqualTo(voteRequest.participateCode()), + () -> assertThat(response.team()).isEqualTo(voteRequest.team()) + ); + } + + @ParameterizedTest + @NullAndEmptyAndBlankSource + void 투표_시_이름은_널이거나_빈_문자열일_수_없다(String invalidName) { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + VoteRequest voteRequest = getVoteRequestBuilder() + .set("name", invalidName) + .sample(); + + given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", pollEntity.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + void 투표_시_팀은_널일_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + VoteRequest voteRequest = getVoteRequestBuilder() + .set("team", null) + .sample(); + + given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", pollEntity.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @ParameterizedTest + @NullAndEmptyAndBlankSource + void 투표_시_참여코드는_널이거나_빈_문자열일_수_없다(String participateCode) { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + VoteRequest voteRequest = getVoteRequestBuilder() + .set("participateCode", participateCode) + .sample(); + + given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", pollEntity.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + void 이미_참여한_선거에_투표_할_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + String participatecode = UUID.randomUUID().toString(); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리", participatecode); + VoteRequest voteRequest = getVoteRequestBuilder() + .set("participateCode", participatecode) + .sample(); + + given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", pollEntity.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + void 끝난_선거에_투표_할_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity alreadyDonePoll = pollGenerator.generate(table, PollStatus.DONE); + VoteRequest voteRequest = getVoteRequestBuilder().sample(); + + given() + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", alreadyDonePoll.getId()) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(HttpStatus.BAD_REQUEST.value()); + } + } +} diff --git a/src/test/java/com/debatetimer/controller/poll/VoteDocumentTest.java b/src/test/java/com/debatetimer/controller/poll/VoteDocumentTest.java new file mode 100644 index 00000000..998c6989 --- /dev/null +++ b/src/test/java/com/debatetimer/controller/poll/VoteDocumentTest.java @@ -0,0 +1,156 @@ +package com.debatetimer.controller.poll; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; + +import com.debatetimer.controller.BaseDocumentTest; +import com.debatetimer.controller.RestDocumentationRequest; +import com.debatetimer.controller.RestDocumentationResponse; +import com.debatetimer.controller.Tag; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domain.poll.VoteTeam; +import com.debatetimer.dto.poll.request.VoteRequest; +import com.debatetimer.dto.poll.response.VoteCreateResponse; +import com.debatetimer.dto.poll.response.VoterPollInfoResponse; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import io.restassured.http.ContentType; +import java.util.UUID; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +public class VoteDocumentTest extends BaseDocumentTest { + + @Nested + class GetVotersPollInfo { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.POLL_API) + .summary("투표자 - 선거 정보 조회") + .pathParameter( + parameterWithName("pollId").description("선거 ID") + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("선거 ID"), + fieldWithPath("status").type(STRING).description("선거 상태 - 진행중 : PROGRESS, 완료 : DONE"), + fieldWithPath("prosTeamName").type(STRING).description("찬성측 팀 이름"), + fieldWithPath("consTeamName").type(STRING).description("반대측 팀 이름"), + fieldWithPath("participateCode").type(STRING).description("참여 코드"), + fieldWithPath("totalCount").type(NUMBER).description("전체 투표 수"), + fieldWithPath("prosCount").type(NUMBER).description("찬성 투표 수"), + fieldWithPath("consCount").type(NUMBER).description("반대 투표 수") + ); + + @Test + void 투표자_선거_정보_조회() { + VoterPollInfoResponse response = new VoterPollInfoResponse( + 1L, + PollStatus.PROGRESS, + "찬성", + "반대", + UUID.randomUUID().toString(), + 3L, + 2L, + 1L + ); + doReturn(response).when(voteService).getVoterPollInfo(anyLong()); + + var document = document("vote/get", 200) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .pathParam("pollId", 1l) + .when().get("/api/polls/{pollId}/votes") + .then().statusCode(200); + } + } + + @Nested + class VotePoll { + + private final RestDocumentationRequest requestDocument = request() + .tag(Tag.POLL_API) + .summary("투표자 - 선거 투표") + .pathParameter( + parameterWithName("pollId").description("선거 ID") + ) + .requestBodyField( + fieldWithPath("name").type(STRING).description("투표자 이름"), + fieldWithPath("participateCode").type(STRING).description("투표 참여 코드"), + fieldWithPath("team").type(STRING).description("투표 팀") + ); + + private final RestDocumentationResponse responseDocument = response() + .responseBodyField( + fieldWithPath("id").type(NUMBER).description("투표 ID"), + fieldWithPath("name").type(STRING).description("투표자 이름"), + fieldWithPath("participateCode").type(STRING).description("투표 참여 코드"), + fieldWithPath("team").type(STRING).description("투표 팀") + ); + + @Test + void 투표자_선거_정보_조회() { + VoteRequest voteRequest = new VoteRequest("콜리", UUID.randomUUID().toString(), VoteTeam.PROS); + VoteCreateResponse response = new VoteCreateResponse( + 1L, + voteRequest.name(), + voteRequest.participateCode(), + voteRequest.team() + ); + doReturn(response).when(voteService).vote(anyLong(), any()); + + var document = document("vote/post", 201) + .request(requestDocument) + .response(responseDocument) + .build(); + + given(document) + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", 1l) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(201); + } + + @EnumSource( + value = ClientErrorCode.class, + names = { + "ALREADY_DONE_POLL", + "ALREADY_VOTED_PARTICIPANT", + "INVALID_POLL_PARTICIPANT_CODE", + "INVALID_POLL_PARTICIPANT_NAME" + } + ) + @ParameterizedTest + void 투표자_투표_실패(ClientErrorCode clientErrorCode) { + VoteRequest voteRequest = new VoteRequest("콜리", UUID.randomUUID().toString(), VoteTeam.PROS); + doThrow(new DTClientErrorException(clientErrorCode)).when(voteService).vote(anyLong(), any()); + + var document = document("vote/post", clientErrorCode) + .request(requestDocument) + .response(ERROR_RESPONSE) + .build(); + + given(document) + .contentType(ContentType.JSON) + .body(voteRequest) + .pathParam("pollId", 1l) + .when().post("/api/polls/{pollId}/votes") + .then().statusCode(clientErrorCode.getStatus().value()); + + } + } +} diff --git a/src/test/java/com/debatetimer/domainrepository/poll/VoteDomainRepositoryTest.java b/src/test/java/com/debatetimer/domainrepository/poll/VoteDomainRepositoryTest.java index 068e782e..11ed5d33 100644 --- a/src/test/java/com/debatetimer/domainrepository/poll/VoteDomainRepositoryTest.java +++ b/src/test/java/com/debatetimer/domainrepository/poll/VoteDomainRepositoryTest.java @@ -1,15 +1,21 @@ package com.debatetimer.domainrepository.poll; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.poll.ParticipateCode; import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domain.poll.Vote; import com.debatetimer.domain.poll.VoteInfo; import com.debatetimer.domain.poll.VoteTeam; import com.debatetimer.domainrepository.BaseDomainRepositoryTest; import com.debatetimer.entity.customize.CustomizeTableEntity; import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import java.util.UUID; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -42,4 +48,60 @@ class GetVoteInfo { } } + @Nested + class isExists { + + @Test + void 이미_참여한_투표인지_알_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity alreadyParticipatedPoll = pollGenerator.generate(table, PollStatus.PROGRESS); + PollEntity notYetParticipatedPoll = pollGenerator.generate(table, PollStatus.PROGRESS); + ParticipateCode participateCode = new ParticipateCode(UUID.randomUUID().toString()); + voteGenerator.generate(alreadyParticipatedPoll, VoteTeam.PROS, "콜리", participateCode.getValue()); + + boolean participated = voteDomainRepository.isExists(alreadyParticipatedPoll.getId(), participateCode); + boolean notYetParticipated = voteDomainRepository.isExists(notYetParticipatedPoll.getId(), + participateCode); + + assertAll( + () -> assertThat(participated).isTrue(), + () -> assertThat(notYetParticipated).isFalse() + ); + } + } + + @Nested + class Save { + + @Test + void 투표를_저장할_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + Vote vote = new Vote(pollEntity.getId(), VoteTeam.PROS, "콜리", UUID.randomUUID().toString()); + + Vote savedVote = voteDomainRepository.save(vote); + + assertAll( + () -> assertThat(savedVote.getName().getValue()).isEqualTo(vote.getName().getValue()), + () -> assertThat(savedVote.getCode().getValue()).isEqualTo(vote.getCode().getValue()), + () -> assertThat(savedVote.getTeam()).isEqualTo(vote.getTeam()) + ); + } + + @Test + void 중복_투표할_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + String participateCode = UUID.randomUUID().toString(); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리", participateCode); + Vote vote = new Vote(pollEntity.getId(), VoteTeam.PROS, "콜리", participateCode); + + assertThatThrownBy(() -> voteDomainRepository.save(vote)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.ALREADY_VOTED_PARTICIPANT.getMessage()); + } + } } diff --git a/src/test/java/com/debatetimer/exception/decoder/H2ErrorDecoderTest.java b/src/test/java/com/debatetimer/exception/decoder/H2ErrorDecoderTest.java new file mode 100644 index 00000000..147ff327 --- /dev/null +++ b/src/test/java/com/debatetimer/exception/decoder/H2ErrorDecoderTest.java @@ -0,0 +1,51 @@ +package com.debatetimer.exception.decoder; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +class H2ErrorDecoderTest { + + private H2ErrorDecoder errorDecoder; + + + @BeforeEach + void setUp() { + errorDecoder = new H2ErrorDecoder(); + } + + @Nested + class isUniqueError { + + @Test + void 유니크_제약조건_에러를_판단할_수_있다() { + SQLException uniqueError = new SQLException("유니크 에러", H2ErrorDecoder.UNIQUE_CONSTRAINT_VIOLATION_SQL_STATE); + ConstraintViolationException uniqueViolation = new ConstraintViolationException("유니크 에러", uniqueError, + "vote_poll_id_participate_code"); + DataIntegrityViolationException uniqueException = new DataIntegrityViolationException("유니크 에러", + uniqueViolation); + + boolean isUniqueError = errorDecoder.isUniqueConstraintViolation(uniqueException); + + assertThat(isUniqueError).isTrue(); + } + + @Test + void 유니크_제약조건_에러가_아님을_판단한다() { + SQLException notUniqueError = new SQLException("다른 에러", "23000"); + ConstraintViolationException notUniqueViolation = new ConstraintViolationException("기타 에러", notUniqueError, + "some_constraint"); + DataIntegrityViolationException extraException = new DataIntegrityViolationException("에러", + notUniqueViolation); + + boolean isNotUniqueError = errorDecoder.isUniqueConstraintViolation(extraException); + + assertThat(isNotUniqueError).isFalse(); + } + } +} diff --git a/src/test/java/com/debatetimer/exception/decoder/MySqlErrorDecoderTest.java b/src/test/java/com/debatetimer/exception/decoder/MySqlErrorDecoderTest.java new file mode 100644 index 00000000..f49d5978 --- /dev/null +++ b/src/test/java/com/debatetimer/exception/decoder/MySqlErrorDecoderTest.java @@ -0,0 +1,54 @@ +package com.debatetimer.exception.decoder; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.sql.SQLException; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +class MySqlErrorDecoderTest { + + private MySqlErrorDecoder errorDecoder; + + + @BeforeEach + void setUp() { + errorDecoder = new MySqlErrorDecoder(); + } + + @Nested + class isUniqueError { + + @Test + void 유니크_제약조건_에러를_판단할_수_있다() { + SQLException uniqueError = new SQLException("유니크 에러", + MySqlErrorDecoder.MYSQL_UNIQUE_VIOLATION, + MySqlErrorDecoder.MYSQL_DUP_ERROR_CODE + ); + ConstraintViolationException uniqueViolation = new ConstraintViolationException("유니크 에러", uniqueError, + "vote_poll_id_participate_code"); + DataIntegrityViolationException uniqueException = new DataIntegrityViolationException("유니크 에러", + uniqueViolation); + + boolean isUniqueError = errorDecoder.isUniqueConstraintViolation(uniqueException); + + assertThat(isUniqueError).isTrue(); + } + + @Test + void 유니크_제약조건_에러가_아님을_판단한다() { + SQLException notUniqueError = new SQLException("다른 에러", "32050", 1234); + ConstraintViolationException notUniqueViolation = new ConstraintViolationException("기타 에러", notUniqueError, + "some_constraint"); + DataIntegrityViolationException extraException = new DataIntegrityViolationException("에러", + notUniqueViolation); + + boolean isNotUniqueError = errorDecoder.isUniqueConstraintViolation(extraException); + + assertThat(isNotUniqueError).isFalse(); + } + } +} diff --git a/src/test/java/com/debatetimer/fixture/VoteGenerator.java b/src/test/java/com/debatetimer/fixture/VoteGenerator.java index 9efdd80c..ed97b1f1 100644 --- a/src/test/java/com/debatetimer/fixture/VoteGenerator.java +++ b/src/test/java/com/debatetimer/fixture/VoteGenerator.java @@ -17,7 +17,11 @@ public VoteGenerator(VoteRepository voteRepository) { } public VoteEntity generate(PollEntity pollEntity, VoteTeam team, String name) { - VoteEntity vote = new VoteEntity(null, pollEntity, team, name, UUID.randomUUID().toString()); + return generate(pollEntity, team, name, UUID.randomUUID().toString()); + } + + public VoteEntity generate(PollEntity pollEntity, VoteTeam team, String name, String code) { + VoteEntity vote = new VoteEntity(null, pollEntity, team, name, code); return voteRepository.save(vote); } } diff --git a/src/test/java/com/debatetimer/service/poll/VoteServiceTest.java b/src/test/java/com/debatetimer/service/poll/VoteServiceTest.java new file mode 100644 index 00000000..52626c73 --- /dev/null +++ b/src/test/java/com/debatetimer/service/poll/VoteServiceTest.java @@ -0,0 +1,121 @@ +package com.debatetimer.service.poll; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.debatetimer.domain.member.Member; +import com.debatetimer.domain.poll.PollStatus; +import com.debatetimer.domain.poll.VoteTeam; +import com.debatetimer.dto.poll.request.VoteRequest; +import com.debatetimer.dto.poll.response.VoteCreateResponse; +import com.debatetimer.dto.poll.response.VoterPollInfoResponse; +import com.debatetimer.entity.customize.CustomizeTableEntity; +import com.debatetimer.entity.poll.PollEntity; +import com.debatetimer.exception.custom.DTClientErrorException; +import com.debatetimer.exception.errorcode.ClientErrorCode; +import com.debatetimer.repository.poll.VoteRepository; +import com.debatetimer.service.BaseServiceTest; +import java.util.UUID; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class VoteServiceTest extends BaseServiceTest { + + @Autowired + private VoteService voteService; + + @Autowired + private VoteRepository voteRepository; + + @Nested + class Vote { + + @Test + void 진행_중인_선거에_최초로_투표_할_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + String participateCode = UUID.randomUUID().toString(); + VoteRequest voteRequest = new VoteRequest("콜리", participateCode, VoteTeam.PROS); + + VoteCreateResponse response = voteService.vote(pollEntity.getId(), voteRequest); + + assertAll( + () -> assertThat(response.name()).isEqualTo(voteRequest.name()), + () -> assertThat(response.participateCode()).isEqualTo(voteRequest.participateCode()), + () -> assertThat(response.team()).isEqualTo(voteRequest.team()) + ); + } + + @Test + void 이미_참여한_선거에_투표_할_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + String participateCode = UUID.randomUUID().toString(); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리", participateCode); + VoteRequest voteRequest = new VoteRequest("콜리", participateCode, VoteTeam.PROS); + + assertThatThrownBy(() -> voteService.vote(pollEntity.getId(), voteRequest)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.ALREADY_VOTED_PARTICIPANT.getMessage()); + } + + @Test + void 투표_동시성_이슈에_단일_표만_유효하게_취급한다() throws InterruptedException { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + String participateCode = UUID.randomUUID().toString(); + VoteRequest voteRequest = new VoteRequest("콜리", participateCode, VoteTeam.PROS); + + runAtSameTime(2, () -> voteService.vote(pollEntity.getId(), voteRequest)); + + long voteCount = voteRepository.count(); + assertThat(voteCount).isEqualTo(1); + } + + @Test + void 끝난_선거에_투표_할_수_없다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity alreadyDonePoll = pollGenerator.generate(table, PollStatus.DONE); + String participateCode = UUID.randomUUID().toString(); + VoteRequest voteRequest = new VoteRequest("콜리", participateCode, VoteTeam.PROS); + + assertThatThrownBy(() -> voteService.vote(alreadyDonePoll.getId(), voteRequest)) + .isInstanceOf(DTClientErrorException.class) + .hasMessage(ClientErrorCode.ALREADY_DONE_POLL.getMessage()); + } + } + + @Nested + class GetVoterPollInfo { + + @Test + void 투표자가_선거정보를_조회할_수_있다() { + Member member = memberGenerator.generate("email@email.com"); + CustomizeTableEntity table = customizeTableGenerator.generate(member); + PollEntity pollEntity = pollGenerator.generate(table, PollStatus.PROGRESS); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "콜리"); + voteGenerator.generate(pollEntity, VoteTeam.PROS, "비토"); + voteGenerator.generate(pollEntity, VoteTeam.CONS, "커찬"); + + VoterPollInfoResponse response = voteService.getVoterPollInfo(pollEntity.getId()); + + assertAll( + () -> assertThat(response.id()).isEqualTo(pollEntity.getId()), + () -> assertThat(response.prosTeamName()).isEqualTo(pollEntity.getProsTeamName()), + () -> assertThat(response.consTeamName()).isEqualTo(pollEntity.getConsTeamName()), + () -> assertThat(response.status()).isEqualTo(pollEntity.getStatus()), + () -> assertThat(response.totalCount()).isEqualTo(3L), + () -> assertThat(response.participateCode()).isNotBlank(), + () -> assertThat(response.prosCount()).isEqualTo(2L), + () -> assertThat(response.consCount()).isEqualTo(1L) + ); + } + } +}