From 033264b4906f5e672cf8e04a201ea0df0c1fc93b Mon Sep 17 00:00:00 2001 From: pykido Date: Mon, 26 Jan 2026 21:31:34 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat=20:=20=ED=88=AC=ED=91=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B0=8F=20=ED=88=AC=ED=91=9C=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../convenience/ContestConvenience.java | 8 ++ .../exception/ContestExceptionType.java | 3 +- .../modules/team/api/TeamVoteController.java | 45 ++++++++ .../application/TeamVoteCommandService.java | 108 ++++++++++++++++++ .../dto/request/TeamVoteToggleRequest.java | 9 ++ .../dto/response/MemberVoteCountResponse.java | 7 ++ .../dto/response/TeamVoteToggleResponse.java | 15 +++ .../opus/opus/modules/team/domain/Team.java | 4 +- .../opus/modules/team/domain/TeamVote.java | 47 ++++++++ .../domain/dao/TeamCommentRepository.java | 2 - .../team/domain/dao/TeamVoteRepository.java | 16 +++ .../team/exception/TeamVoteException.java | 24 ++++ .../team/exception/TeamVoteExceptionType.java | 29 +++++ 13 files changed, 313 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/request/TeamVoteToggleRequest.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/response/MemberVoteCountResponse.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/response/TeamVoteToggleResponse.java create mode 100644 src/main/java/com/opus/opus/modules/team/domain/TeamVote.java create mode 100644 src/main/java/com/opus/opus/modules/team/domain/dao/TeamVoteRepository.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamVoteException.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java index 627bbe01..d0f22016 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java @@ -4,6 +4,7 @@ import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.dao.ContestRepository; @@ -47,4 +48,11 @@ public long countCurrentContests() { public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } + + public void validateVotingPeriod(final Long contestId) { + Contest contest = getValidateExistContest(contestId); + if (!contest.isVotingPeriod()) { + throw new ContestException(NOT_VOTE_PERIOD_NOW); + } + } } diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java index 3cc966f0..1a23b23a 100644 --- a/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java +++ b/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java @@ -12,7 +12,8 @@ public enum ContestExceptionType implements BaseExceptionType { CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."), CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."), VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."), - CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.") + CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다."), + NOT_VOTE_PERIOD_NOW(HttpStatus.BAD_REQUEST, "지금은 투표 기간이 아닙니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java b/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java new file mode 100644 index 00000000..779c4f48 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java @@ -0,0 +1,45 @@ +package com.opus.opus.modules.team.api; + +import com.opus.opus.global.security.annotation.LoginMember; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.TeamVoteCommandService; +import com.opus.opus.modules.team.application.dto.request.TeamVoteToggleRequest; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/teams") +@Secured({"ROLE_회원", "ROLE_관리자"}) +public class TeamVoteController { + + private final TeamVoteCommandService teamVoteCommandService; + + @PatchMapping("/{teamId}/votes") + public ResponseEntity toggleVote( + @PathVariable Long teamId, + @RequestBody @Valid TeamVoteToggleRequest request, + @LoginMember Member member) { + TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), teamId, request.isVoted()); + return ResponseEntity.ok(response); + } + + @GetMapping("/votes") + public ResponseEntity getMemberVoteCount( + @RequestParam Long contestId, + @LoginMember Member member) { + MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contestId); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java new file mode 100644 index 00000000..ef64f138 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java @@ -0,0 +1,108 @@ +package com.opus.opus.modules.team.application; + +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.VOTE_LIMIT_EXCEEDED; + +import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.team.application.convenience.TeamConvenience; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamVote; +import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; +import com.opus.opus.modules.team.exception.TeamVoteException; +import com.opus.opus.modules.team.exception.TeamVoteExceptionType; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TeamVoteCommandService { + + private final TeamConvenience teamConvenience; + private final ContestConvenience contestConvenience; + + private final TeamVoteRepository teamVoteRepository; + + public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isVoted) { + Team team = teamConvenience.getValidateExistTeam(teamId); + Contest contest = contestConvenience.getValidateExistContest(team.getContestId()); + + contestConvenience.validateVotingPeriod(team.getContestId()); + + Optional teamVoteOptional = teamVoteRepository.findByMemberIdAndTeam(memberId, team); + + return teamVoteOptional.map(teamVote -> handleExistingVote(teamVote, isVoted, memberId, contest)) + .orElseGet(() -> handleFirstTimeVote(memberId, team, isVoted, contest)); + } + + private TeamVoteToggleResponse handleFirstTimeVote(Long memberId, Team team, Boolean isVoted, Contest contest) { + long currentVoteCount = countCurrentMemberVotes(memberId, team.getContestId()); + int maxVotesLimit = contest.getMaxVotesLimit(); + + if (isVoted) { + validateVoteLimit(currentVoteCount, maxVotesLimit); + currentVoteCount++; + } + + saveTeamVote(memberId, team, isVoted); + + String message = isVoted ? "투표가 처음 등록되었습니다." : "투표가 비활성화된 상태로 초기화되었습니다."; + return TeamVoteToggleResponse.of(team.getId(), isVoted, message, currentVoteCount, maxVotesLimit); + } + + private TeamVoteToggleResponse handleExistingVote(TeamVote teamVote, Boolean isVoted, Long memberId, Contest contest) { + if (Objects.equals(teamVote.getIsVoted(), isVoted)) { + TeamVoteExceptionType exceptionType = isVoted ? ALREADY_VOTED : ALREADY_UNVOTED; + throw new TeamVoteException(exceptionType); + } + + long currentVoteCount = countCurrentMemberVotes(memberId, contest.getId()); + int maxVotesLimit = contest.getMaxVotesLimit(); + + if (isVoted) { + validateVoteLimit(currentVoteCount, maxVotesLimit); + currentVoteCount++; + } else { + currentVoteCount--; + } + + teamVote.updateIsVoted(isVoted); + + String message = isVoted ? "투표가 등록되었습니다." : "투표가 취소되었습니다."; + return TeamVoteToggleResponse.of(teamVote.getTeam().getId(), isVoted, message, currentVoteCount, maxVotesLimit); + } + + private long countCurrentMemberVotes(Long memberId, Long contestId) { + return teamVoteRepository.countMemberVotesInContest(memberId, contestId); + } + + private void validateVoteLimit(long currentVoteCount, int maxVotesLimit) { + if (currentVoteCount >= maxVotesLimit) { + String message = String.format(VOTE_LIMIT_EXCEEDED.errorMessage(), maxVotesLimit); + throw new TeamVoteException(VOTE_LIMIT_EXCEEDED, message); + } + } + + private void saveTeamVote(Long memberId, Team team, Boolean isVoted) { + teamVoteRepository.save(TeamVote.builder() + .memberId(memberId) + .team(team) + .isVoted(isVoted) + .build()); + } + + @Transactional(readOnly = true) + public MemberVoteCountResponse getMemberVoteCount(Long memberId, Long contestId) { + Contest contest = contestConvenience.getValidateExistContest(contestId); + long currentVoteCount = teamVoteRepository.countMemberVotesInContest(memberId, contestId); + long remainingVotesCount = contest.getMaxVotesLimit() - currentVoteCount; + return new MemberVoteCountResponse(remainingVotesCount, (long) contest.getMaxVotesLimit()); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamVoteToggleRequest.java b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamVoteToggleRequest.java new file mode 100644 index 00000000..894ea40f --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamVoteToggleRequest.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.team.application.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record TeamVoteToggleRequest( + @NotNull(message = "isVoted 값은 필수입니다.") + Boolean isVoted +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/response/MemberVoteCountResponse.java b/src/main/java/com/opus/opus/modules/team/application/dto/response/MemberVoteCountResponse.java new file mode 100644 index 00000000..d870d348 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/response/MemberVoteCountResponse.java @@ -0,0 +1,7 @@ +package com.opus.opus.modules.team.application.dto.response; + +public record MemberVoteCountResponse( + Long remainingVotesCount, + Long maxVotesLimit +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamVoteToggleResponse.java b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamVoteToggleResponse.java new file mode 100644 index 00000000..38712a1e --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamVoteToggleResponse.java @@ -0,0 +1,15 @@ +package com.opus.opus.modules.team.application.dto.response; + +public record TeamVoteToggleResponse( + Long teamId, + Boolean isVoted, + String message, + Long remainingVotesCount, + Long maxVotesLimit +) { + public static TeamVoteToggleResponse of(Long teamId, Boolean isVoted, String message, + long currentVoteCount, long maxVotesLimit) { + long remainingVotesCount = maxVotesLimit - currentVoteCount; + return new TeamVoteToggleResponse(teamId, isVoted, message, remainingVotesCount, maxVotesLimit); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/Team.java b/src/main/java/com/opus/opus/modules/team/domain/Team.java index 37f0bbb3..554ca4a4 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/Team.java +++ b/src/main/java/com/opus/opus/modules/team/domain/Team.java @@ -77,6 +77,9 @@ public class Team extends BaseEntity { @OneToMany(mappedBy = "team") private List teamLikes = new ArrayList<>(); + @OneToMany(mappedBy = "team") + private List teamVotes = new ArrayList<>(); + @Builder private Team(final String teamName, final String projectName, final String professorName, final String overview, final String githubPath, final String productionPath, final String youTubePath, final Long contestId, @@ -95,5 +98,4 @@ private Team(final String teamName, final String projectName, final String profe this.isSubmitted = false; this.teamMembers = teamMembers; } - } diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java b/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java new file mode 100644 index 00000000..2f90fc8e --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java @@ -0,0 +1,47 @@ +package com.opus.opus.modules.team.domain; + +import static jakarta.persistence.FetchType.LAZY; + +import com.opus.opus.global.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TeamVote extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long memberId; + + @ManyToOne(fetch = LAZY) + @JoinColumn(name = "team_id", nullable = false) + private Team team; + + @Column(nullable = false) + private Boolean isVoted; + + @Builder + private TeamVote(final Long memberId, final Team team, final Boolean isVoted) { + this.memberId = memberId; + this.team = team; + this.isVoted = isVoted; + } + + public void updateIsVoted(final Boolean isVoted) { + this.isVoted = isVoted; + } +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java index d501f110..1cb6b77f 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java @@ -6,6 +6,4 @@ public interface TeamCommentRepository extends JpaRepository { List findAllByTeamIdOrderByIdDesc(Long id); - - void deleteAllByTeamId(Long teamId); } diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamVoteRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamVoteRepository.java new file mode 100644 index 00000000..b4eec756 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamVoteRepository.java @@ -0,0 +1,16 @@ +package com.opus.opus.modules.team.domain.dao; + +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamVote; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +public interface TeamVoteRepository extends JpaRepository { + + Optional findByMemberIdAndTeam(Long memberId, Team team); + + @Query("SELECT COUNT(tv) FROM TeamVote tv JOIN tv.team t " + + "WHERE tv.memberId = :memberId AND tv.isVoted = true AND t.contestId = :contestId") + long countMemberVotesInContest(Long memberId, Long contestId); +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamVoteException.java b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteException.java new file mode 100644 index 00000000..42cc89d8 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteException.java @@ -0,0 +1,24 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class TeamVoteException extends BaseException { + + private final TeamVoteExceptionType exceptionType; + + public TeamVoteException(final TeamVoteExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + public TeamVoteException(final TeamVoteExceptionType exceptionType, final String message) { + super(message); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java new file mode 100644 index 00000000..7c7e81c9 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java @@ -0,0 +1,29 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum TeamVoteExceptionType implements BaseExceptionType { + + ALREADY_VOTED(HttpStatus.BAD_REQUEST, "이미 투표한 팀입니다."), + ALREADY_UNVOTED(HttpStatus.BAD_REQUEST, "이미 투표를 취소한 팀입니다."), + VOTE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "대회당 최대 %d개 팀만 투표할 수 있습니다."); + + private final HttpStatus httpStatus; + private final String message; + + TeamVoteExceptionType(final HttpStatus httpStatus, final String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return message; + } +} From 865487876bbf8732bb6f6794756fcf62e6ee4420 Mon Sep 17 00:00:00 2001 From: pykido Date: Mon, 26 Jan 2026 22:03:07 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat=20:=20=ED=88=AC=ED=91=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B0=8F=20=ED=88=AC=ED=91=9C=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema.sql | 11 + .../java/com/opus/opus/team/TeamFixture.java | 8 +- .../com/opus/opus/team/TeamVoteFixture.java | 15 ++ .../TeamVoteCommandServiceTest.java | 205 ++++++++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/opus/opus/team/TeamVoteFixture.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6d06ffbd..3c128e33 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -15,6 +15,7 @@ DROP TABLE IF EXISTS `team_like`; DROP TABLE IF EXISTS `team_member`; DROP TABLE IF EXISTS `team_member_roles`; DROP TABLE IF EXISTS `team_sort`; +DROP TABLE IF EXISTS `team_vote`; CREATE TABLE `contest` ( `id` bigint NOT NULL AUTO_INCREMENT, @@ -178,3 +179,13 @@ CREATE TABLE `team_sort` ( `team_id` bigint NOT NULL, PRIMARY KEY (`id`) ); + +CREATE TABLE `team_vote` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `is_voted` bit(1) NOT NULL, + `member_id` bigint NOT NULL, + `team_id` bigint NOT NULL, + PRIMARY KEY (`id`) +); diff --git a/src/test/java/com/opus/opus/team/TeamFixture.java b/src/test/java/com/opus/opus/team/TeamFixture.java index 3a5c34d2..7a90d5a7 100644 --- a/src/test/java/com/opus/opus/team/TeamFixture.java +++ b/src/test/java/com/opus/opus/team/TeamFixture.java @@ -5,7 +5,13 @@ public class TeamFixture { + private static final Long DEFAULT_CONTEST_ID = 1L; + public static Team createTeam() { + return createTeam(DEFAULT_CONTEST_ID); + } + + public static Team createTeam(final Long contestId) { return Team.builder() .teamName("팀 옵스") .projectName("옵스 프로젝트") @@ -14,7 +20,7 @@ public static Team createTeam() { .githubPath("http://github.com/example") .productionPath("http://production.example.com") .youTubePath("http://youtube.com/example") - .contestId(1L) + .contestId(contestId) .trackId(1L) .itemOrder(1) .teamMembers(new ArrayList<>()) diff --git a/src/test/java/com/opus/opus/team/TeamVoteFixture.java b/src/test/java/com/opus/opus/team/TeamVoteFixture.java new file mode 100644 index 00000000..a2f3de51 --- /dev/null +++ b/src/test/java/com/opus/opus/team/TeamVoteFixture.java @@ -0,0 +1,15 @@ +package com.opus.opus.team; + +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamVote; + +public class TeamVoteFixture { + + public static TeamVote createTeamVote(final Team team, final Long memberId, final Boolean isVoted) { + return TeamVote.builder() + .team(team) + .memberId(memberId) + .isVoted(isVoted) + .build(); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java new file mode 100644 index 00000000..d1063d24 --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java @@ -0,0 +1,205 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.opus.opus.contest.ContestFixture; +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.application.TeamVoteCommandService; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamVote; +import com.opus.opus.modules.team.domain.dao.TeamRepository; +import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.modules.team.exception.TeamVoteException; +import com.opus.opus.team.TeamFixture; +import com.opus.opus.team.TeamVoteFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class TeamVoteCommandServiceTest extends IntegrationTest { + + @Autowired + private TeamVoteCommandService teamVoteCommandService; + + @Autowired + private TeamRepository teamRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private TeamVoteRepository teamVoteRepository; + @Autowired + private ContestRepository contestRepository; + + private Contest contest; + private Team team; + private Member member; + + @BeforeEach + void setUp() { + Contest newContest = ContestFixture.createContest(); + newContest.updateVotePeriod(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); + newContest.updateMaxVotesLimit(2); // 최대 투표 수를 기본적으로 2로 설정 + contest = contestRepository.save(newContest); + + team = teamRepository.save(TeamFixture.createTeam(contest.getId())); + member = memberRepository.save(MemberFixture.createMember()); + } + + @Test + @DisplayName("[성공] 처음 투표하면 TeamVote가 생성되고 투표가 등록된다.") + void 처음_투표하면_TeamVote가_생성되고_투표가_등록된다() { + TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), team.getId(), true); + + assertThat(response.teamId()).isEqualTo(team.getId()); + assertThat(response.isVoted()).isTrue(); + assertThat(response.message()).isEqualTo("투표가 처음 등록되었습니다."); + assertThat(response.remainingVotesCount()).isEqualTo(1L); + assertThat(response.maxVotesLimit()).isEqualTo(2L); + + TeamVote savedVote = teamVoteRepository.findByMemberIdAndTeam(member.getId(), team).orElseThrow(); + assertThat(savedVote.getIsVoted()).isTrue(); + } + + @Test + @DisplayName("[성공] 처음 요청이 isVoted=false이면 비활성화 상태로 초기화된다.(다만, 일반적으로는 사용하지 않는 플로우임)") + void 처음_요청이_isVoted_false이면_비활성화_상태로_초기화된다() { + TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), team.getId(), false); + + assertThat(response.isVoted()).isFalse(); + assertThat(response.message()).isEqualTo("투표가 비활성화된 상태로 초기화되었습니다."); + assertThat(response.remainingVotesCount()).isEqualTo(2L); + } + + @Test + @DisplayName("[성공] 기존 투표를 취소할 수 있다.") + void 기존_투표를_취소할_수_있다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + MemberVoteCountResponse beforeResponse = teamVoteCommandService.getMemberVoteCount(member.getId(), contest.getId()); + assertThat(beforeResponse.remainingVotesCount()).isEqualTo(1L); + + TeamVoteToggleResponse afterResponse = teamVoteCommandService.toggleVote(member.getId(), team.getId(), false); + + assertThat(afterResponse.isVoted()).isFalse(); + assertThat(afterResponse.message()).isEqualTo("투표가 취소되었습니다."); + assertThat(afterResponse.remainingVotesCount()).isEqualTo(2L); + } + + @Test + @DisplayName("[성공] 취소한 투표를 다시 등록할 수 있다.") + void 취소한_투표를_다시_등록할_수_있다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), false)); + + TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), team.getId(), true); + + assertThat(response.isVoted()).isTrue(); + assertThat(response.message()).isEqualTo("투표가 등록되었습니다."); + assertThat(response.remainingVotesCount()).isEqualTo(1L); + } + + @Test + @DisplayName("[실패] 이미 투표한 팀에 다시 투표하면 예외가 발생한다.") + void 이미_투표한_팀에_다시_투표하면_예외가_발생한다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), team.getId(), true)) + .isInstanceOf(TeamVoteException.class) + .hasMessage(ALREADY_VOTED.errorMessage()); + } + + @Test + @DisplayName("[실패] 이미 투표 취소한 팀에 다시 취소하면 예외가 발생한다.") + void 이미_투표_취소한_팀에_다시_취소하면_예외가_발생한다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), false)); + + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), team.getId(), false)) + .isInstanceOf(TeamVoteException.class) + .hasMessage(ALREADY_UNVOTED.errorMessage()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에는 투표할 수 없다.") + void 존재하지_않는_팀에는_투표할_수_없다() { + final Long invalidTeamId = 999L; + + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), invalidTeamId, true)) + .isInstanceOf(TeamException.class) + .hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[실패] 투표 기간이 아니면 투표할 수 없다.") + void 투표_기간이_아니면_투표할_수_없다() { + Contest notVotingContest = ContestFixture.createContest(); + notVotingContest.updateVotePeriod(LocalDateTime.now().minusDays(10), LocalDateTime.now().minusDays(5)); + notVotingContest.updateMaxVotesLimit(2); + notVotingContest = contestRepository.save(notVotingContest); + + Team notVotingTeam = teamRepository.save(TeamFixture.createTeam(notVotingContest.getId())); + + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), notVotingTeam.getId(), true)) + .isInstanceOf(ContestException.class) + .hasMessage(NOT_VOTE_PERIOD_NOW.errorMessage()); + } + + @Test + @DisplayName("[실패] 최대 투표 수를 초과하면 예외가 발생한다.") + void 최대_투표_수를_초과하면_예외가_발생한다() { + Team secondTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); + Team thirdTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); + + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(secondTeam, member.getId(), true)); + + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), thirdTeam.getId(), true)) + .isInstanceOf(TeamVoteException.class) + .hasMessageContaining("최대 2개 팀만 투표할 수 있습니다."); + } + + @Test + @DisplayName("[성공] 사용자의 남은 투표 개수를 조회할 수 있다.") + void 사용자의_남은_투표_개수를_조회할_수_있다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + + MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contest.getId()); + + assertThat(response.remainingVotesCount()).isEqualTo(1L); + assertThat(response.maxVotesLimit()).isEqualTo(2L); + } + + @Test + @DisplayName("[성공] 투표하지 않은 사용자는 최대 투표 수만큼 남은 투표 개수가 있다.") + void 투표하지_않은_사용자는_최대_투표_수만큼_남은_투표_개수가_있다() { + MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contest.getId()); + + assertThat(response.remainingVotesCount()).isEqualTo(2L); + assertThat(response.maxVotesLimit()).isEqualTo(2L); + } + + @Test + @DisplayName("[성공] 취소한 투표는 카운트에서 제외된다.") + void 취소한_투표는_카운트에서_제외된다() { + teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + Team secondTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(secondTeam, member.getId(), false)); + + MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contest.getId()); + + assertThat(response.remainingVotesCount()).isEqualTo(1L); + } +} From 59f306fb83515dcf3156498f10d92acf6f36b3c4 Mon Sep 17 00:00:00 2001 From: pykido Date: Mon, 26 Jan 2026 22:16:53 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat=20:=20=ED=88=AC=ED=91=9C=20=ED=86=A0?= =?UTF-8?q?=EA=B8=80=20=EB=B0=8F=20=ED=88=AC=ED=91=9C=20=EA=B0=9C=EC=88=98?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/opus/opus/docs/asciidoc/opus.adoc | 1 + .../opus/opus/docs/asciidoc/team-vote.adoc | 191 +++++++++++ .../com/opus/opus/restdocs/RestDocsTest.java | 6 + .../restdocs/docs/TeamVoteApiDocsTest.java | 313 ++++++++++++++++++ 4 files changed, 511 insertions(+) create mode 100644 src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc create mode 100644 src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java diff --git a/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc b/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc index d782dfa2..f5ceec17 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc @@ -14,6 +14,7 @@ link:./member.html[회원 API] == 팀 관련 API link:./team-comment.html[팀 댓글 API] +link:./team-vote.html[팀 투표 API] == 대회 관련 API link:./contest.html[대회 API] diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc new file mode 100644 index 00000000..6a57113d --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc @@ -0,0 +1,191 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += TEAM VOTE API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:./opus.html[API 목록으로 돌아가기] + +== `PATCH`: 팀 투표 토글 + +해당 팀에 대해 투표 상태를 토글합니다. + +* `isVoted: true` → 투표 등록 +* `isVoted: false` → 투표 취소 + +NOTE: 대회 기간 중에는 투표만 보이고, 대회 기간이 아니라면 좋아요가 보입니다. + +NOTE: 해당 대회의 최대 투표 개수 이상으로 투표를 등록할 수 없습니다. + +.Path Parameters +include::{snippets}/toggle-team-vote/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/toggle-team-vote/request-headers.adoc[] + +.Request Fields +include::{snippets}/toggle-team-vote/request-fields.adoc[] + +.Response Fields +include::{snippets}/toggle-team-vote/response-fields.adoc[] + +=== 시나리오 1: TeamVote 데이터가 없는 경우 + +특정 멤버가 특정 팀에 대해 투표 API를 처음 호출하면, TeamVote 테이블에 데이터가 새로 생성됩니다. + +[cols="1,2,1"] +|=== +|Request isVoted |응답 메시지 |HTTP 상태 코드 + +|true +|투표가 처음 등록되었습니다. +|200 OK + +|false +|투표가 비활성화된 상태로 초기화되었습니다. +|200 OK +|=== + +isVoted: true → 투표 등록 + +include::{snippets}/toggle-team-vote/http-request.adoc[] + +include::{snippets}/toggle-team-vote/http-response.adoc[] + +=== 시나리오 2: TeamVote 데이터가 있는 경우 + +이미 해당 팀에 대한 투표 기록이 있는 경우, 상태에 따라 토글됩니다. + +[cols="1,1,2,1"] +|=== +|현재 isVoted |Request isVoted |응답 메시지 |HTTP 상태 코드 + +|true +|true +|이미 투표한 팀입니다. +|400 Bad Request + +|true +|false +|투표가 취소되었습니다. +|200 OK + +|false +|true +|투표가 등록되었습니다. +|200 OK + +|false +|false +|이미 투표를 취소한 팀입니다. +|400 Bad Request +|=== + +투표 취소 (isVoted: true → false) + +include::{snippets}/cancel-team-vote/http-request.adoc[] + +include::{snippets}/cancel-team-vote/http-response.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 팀 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-not-found/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-not-found/http-response.adoc[] + +==== + +.❌ Case 2: 이미 투표한 팀 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-already-voted/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-already-voted/http-response.adoc[] + +==== + +.❌ Case 3: 이미 투표 취소한 팀 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-already-unvoted/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-already-unvoted/http-response.adoc[] + +==== + +.❌ Case 4: 최대 투표 수 초과 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-limit-exceeded/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-limit-exceeded/http-response.adoc[] + +==== + +.❌ Case 5: 투표 기간이 아님 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-not-vote-period/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-not-vote-period/http-response.adoc[] + +==== + +== `GET`: 사용자 투표 개수 조회 + +현재 사용자가 특정 대회에서 투표한 팀의 개수를 조회합니다. + +.Query Parameters +include::{snippets}/get-member-vote-count/query-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/get-member-vote-count/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-member-vote-count/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-member-vote-count/http-response.adoc[] + +.Response Fields +include::{snippets}/get-member-vote-count/response-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 + +[%collapsible] + +==== + +include::{snippets}/get-member-vote-count-fail-not-found/http-request.adoc[] + +include::{snippets}/get-member-vote-count-fail-not-found/http-response.adoc[] + +==== diff --git a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java index 3b3c0006..11199221 100644 --- a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java @@ -15,11 +15,13 @@ import com.opus.opus.modules.member.application.MemberQueryService; import com.opus.opus.modules.member.domain.dao.MemberRepository; import com.opus.opus.modules.team.api.TeamCommentController; +import com.opus.opus.modules.team.api.TeamVoteController; import com.opus.opus.modules.team.application.TeamCommentCommandService; import com.opus.opus.modules.team.application.TeamCommentQueryService; import com.opus.opus.modules.notice.api.NoticeController; import com.opus.opus.modules.notice.application.NoticeCommandService; import com.opus.opus.modules.notice.application.NoticeQueryService; +import com.opus.opus.modules.team.application.TeamVoteCommandService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -38,6 +40,7 @@ MemberController.class, ContestController.class, TeamCommentController.class, + TeamVoteController.class, NoticeController.class, ContestController.class, }) @@ -58,6 +61,9 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected TeamCommentQueryService teamCommentQueryService; + @MockitoBean + protected TeamVoteCommandService teamVoteCommandService; + @MockitoBean protected NoticeCommandService noticeCommandService; diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java new file mode 100644 index 00000000..a485ca38 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java @@ -0,0 +1,313 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.VOTE_LIMIT_EXCEEDED; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.snippet.Attributes.key; +import static org.springframework.test.util.ReflectionTestUtils.setField; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.dto.request.TeamVoteToggleRequest; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.modules.team.exception.TeamVoteException; +import com.opus.opus.restdocs.RestDocsTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class TeamVoteApiDocsTest extends RestDocsTest { + + private static final String MEMBER_TOKEN = "Bearer member.access.token"; + private static final String TOGGLE_VOTE_DESCRIPTION = """ + 해당 팀에 대해 투표 상태를 토글합니다. + + * isVoted: true → 투표 등록 + * isVoted: false → 투표 취소 + + NOTE: 대회 기간 중에는 투표만 보이고, 대회 기간이 아니라면 좋아요가 보입니다. + + NOTE: 해당 대회의 최대 투표 개수 이상으로 투표를 등록할 수 없습니다. + """; + + private static final String GET_VOTE_COUNT_DESCRIPTION = """ + 현재 사용자가 특정 대회에서 투표한 팀의 개수를 조회합니다. + """; + + private Member member; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember(); + setField(member, "id", 1L); + } + + @Test + @DisplayName("[성공] 팀에 투표를 등록한다.") + void 팀에_투표를_등록한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); + final TeamVoteToggleResponse response = new TeamVoteToggleResponse(1L, true, "투표가 처음 등록되었습니다.", 1L, 2L); + + given(teamVoteCommandService.toggleVote(any(), any(), any())).willReturn(response); + + mockMvc.perform(patch("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("toggle-team-vote", + pathParameters( + parameterWithName("teamId").description("투표할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부 (true: 등록, false: 취소)") + ), + responseFields( + numberFieldWithPath("teamId", "팀 ID"), + booleanFieldWithPath("isVoted", "투표 상태"), + stringFieldWithPath("message", "응답 메시지"), + numberFieldWithPath("remainingVotesCount", "남은 투표 가능 횟수"), + numberFieldWithPath("maxVotesLimit", "대회 최대 투표 허용 개수") + ) + )); + } + + @Test + @DisplayName("[성공] 팀 투표를 취소한다.") + void 팀_투표를_취소한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(false); + final TeamVoteToggleResponse response = new TeamVoteToggleResponse(1L, false, "투표가 취소되었습니다.", 2L, 2L); + + given(teamVoteCommandService.toggleVote(any(), any(), any())).willReturn(response); + + mockMvc.perform(patch("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("cancel-team-vote", + pathParameters( + parameterWithName("teamId").description("투표를 취소할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부 (true: 등록, false: 취소)") + ), + responseFields( + numberFieldWithPath("teamId", "팀 ID"), + booleanFieldWithPath("isVoted", "투표 상태"), + stringFieldWithPath("message", "응답 메시지"), + numberFieldWithPath("remainingVotesCount", "남은 투표 가능 횟수"), + numberFieldWithPath("maxVotesLimit", "대회 최대 투표 허용 개수") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에 투표 시 404 에러를 반환한다.") + void 존재하지_않는_팀에_투표_시_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); + + willThrow(new TeamException(NOT_FOUND_TEAM)) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/votes", 999) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andDo(document("toggle-team-vote-fail-not-found", + pathParameters( + parameterWithName("teamId").description("존재하지 않는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 이미 투표한 팀에 다시 투표 시 400 에러를 반환한다.") + void 이미_투표한_팀에_다시_투표_시_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); + + willThrow(new TeamVoteException(ALREADY_VOTED)) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-vote-fail-already-voted", + pathParameters( + parameterWithName("teamId").description("이미 투표한 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 이미 투표 취소한 팀에 다시 취소 시 400 에러를 반환한다.") + void 이미_투표_취소한_팀에_다시_취소_시_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(false); + + willThrow(new TeamVoteException(ALREADY_UNVOTED)) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-vote-fail-already-unvoted", + pathParameters( + parameterWithName("teamId").description("이미 투표 취소한 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 최대 투표 수 초과 시 400 에러를 반환한다.") + void 최대_투표_수_초과_시_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); + + willThrow(new TeamVoteException(VOTE_LIMIT_EXCEEDED, "대회당 최대 2개 팀만 투표할 수 있습니다.")) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/votes", 3) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-vote-fail-limit-exceeded", + pathParameters( + parameterWithName("teamId").description("투표하려는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 투표 기간이 아닐 때 400 에러를 반환한다.") + void 투표_기간이_아닐_때_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); + + willThrow(new ContestException(NOT_VOTE_PERIOD_NOW)) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-vote-fail-not-vote-period", + pathParameters( + parameterWithName("teamId").description("투표하려는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + + @Test + @DisplayName("[성공] 사용자의 투표 개수를 조회한다.") + void 사용자의_투표_개수를_조회한다() throws Exception { + final MemberVoteCountResponse response = new MemberVoteCountResponse(1L, 2L); + + given(teamVoteCommandService.getMemberVoteCount(any(), any())).willReturn(response); + + mockMvc.perform(get("/teams/votes") + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .param("contestId", "1")) + .andExpect(status().isOk()) + .andDo(document("get-member-vote-count", + queryParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + responseFields( + numberFieldWithPath("remainingVotesCount", "남은 투표 가능 횟수"), + numberFieldWithPath("maxVotesLimit", "대회 최대 투표 허용 개수") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회 ID로 투표 개수 조회 시 404 에러를 반환한다.") + void 존재하지_않는_대회_ID로_투표_개수_조회_시_에러를_반환한다() throws Exception { + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(teamVoteCommandService) + .getMemberVoteCount(any(), any()); + + mockMvc.perform(get("/teams/votes") + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .param("contestId", "999")) + .andExpect(status().isNotFound()) + .andDo(document("get-member-vote-count-fail-not-found", + queryParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ) + )); + } +} From 73c0cb5ddc10b094f9ee4fddfd397d81ad19ab2e Mon Sep 17 00:00:00 2001 From: pykido Date: Tue, 27 Jan 2026 19:01:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor=20:=20=EC=BD=94=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=9F=BF=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20-=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/opus/opus/docs/asciidoc/team-vote.adoc | 2 +- .../opus/restdocs/docs/TeamVoteApiDocsTest.java | 14 -------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc index 6a57113d..95307ab2 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc @@ -159,7 +159,7 @@ include::{snippets}/toggle-team-vote-fail-not-vote-period/http-response.adoc[] == `GET`: 사용자 투표 개수 조회 -현재 사용자가 특정 대회에서 투표한 팀의 개수를 조회합니다. +현재 사용자가 특정 대회에서 남은 투표 가능 횟수와 최대 투표 허용 개수를 조회합니다. .Query Parameters include::{snippets}/get-member-vote-count/query-parameters.adoc[] diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java index a485ca38..96b505e1 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java @@ -41,20 +41,6 @@ public class TeamVoteApiDocsTest extends RestDocsTest { private static final String MEMBER_TOKEN = "Bearer member.access.token"; - private static final String TOGGLE_VOTE_DESCRIPTION = """ - 해당 팀에 대해 투표 상태를 토글합니다. - - * isVoted: true → 투표 등록 - * isVoted: false → 투표 취소 - - NOTE: 대회 기간 중에는 투표만 보이고, 대회 기간이 아니라면 좋아요가 보입니다. - - NOTE: 해당 대회의 최대 투표 개수 이상으로 투표를 등록할 수 없습니다. - """; - - private static final String GET_VOTE_COUNT_DESCRIPTION = """ - 현재 사용자가 특정 대회에서 투표한 팀의 개수를 조회합니다. - """; private Member member; From aad916bf918ddd24527bc802382d188bee3c418b Mon Sep 17 00:00:00 2001 From: pykido Date: Fri, 30 Jan 2026 22:21:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor=20:=20=EC=A7=80=EB=AF=BC=EB=8B=98?= =?UTF-8?q?=20&=20=EC=84=B1=EC=9E=AC=EB=8B=98=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../convenience/ContestConvenience.java | 3 +- .../modules/team/api/TeamVoteController.java | 16 +++--- .../application/TeamVoteCommandService.java | 52 ++++++++++--------- .../opus/modules/team/domain/TeamVote.java | 5 ++ .../team/exception/TeamVoteExceptionType.java | 4 +- .../java/com/opus/opus/team/TeamFixture.java | 4 +- .../TeamVoteCommandServiceTest.java | 10 ++-- 7 files changed, 50 insertions(+), 44 deletions(-) diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java index d0f22016..9e154dca 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java @@ -49,8 +49,7 @@ public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } - public void validateVotingPeriod(final Long contestId) { - Contest contest = getValidateExistContest(contestId); + public void validateVotingPeriod(final Contest contest) { if (!contest.isVotingPeriod()) { throw new ContestException(NOT_VOTE_PERIOD_NOW); } diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java b/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java index 779c4f48..1df9ba14 100644 --- a/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java +++ b/src/main/java/com/opus/opus/modules/team/api/TeamVoteController.java @@ -11,8 +11,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.access.annotation.Secured; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -26,19 +26,17 @@ public class TeamVoteController { private final TeamVoteCommandService teamVoteCommandService; - @PatchMapping("/{teamId}/votes") - public ResponseEntity toggleVote( - @PathVariable Long teamId, - @RequestBody @Valid TeamVoteToggleRequest request, - @LoginMember Member member) { + @PutMapping("/{teamId}/votes") + public ResponseEntity toggleVote(@PathVariable Long teamId, + @RequestBody @Valid TeamVoteToggleRequest request, + @LoginMember Member member) { TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), teamId, request.isVoted()); return ResponseEntity.ok(response); } @GetMapping("/votes") - public ResponseEntity getMemberVoteCount( - @RequestParam Long contestId, - @LoginMember Member member) { + public ResponseEntity getMemberVoteCount(@RequestParam Long contestId, + @LoginMember Member member) { MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contestId); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java index ef64f138..b54cdde6 100644 --- a/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java +++ b/src/main/java/com/opus/opus/modules/team/application/TeamVoteCommandService.java @@ -2,6 +2,8 @@ import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.DUPLICATE_VOTE_REQUEST; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.NOT_VOTED_YET; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.VOTE_LIMIT_EXCEEDED; import com.opus.opus.modules.contest.application.convenience.ContestConvenience; @@ -13,10 +15,10 @@ import com.opus.opus.modules.team.domain.TeamVote; import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; import com.opus.opus.modules.team.exception.TeamVoteException; -import com.opus.opus.modules.team.exception.TeamVoteExceptionType; import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,7 +36,7 @@ public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isV Team team = teamConvenience.getValidateExistTeam(teamId); Contest contest = contestConvenience.getValidateExistContest(team.getContestId()); - contestConvenience.validateVotingPeriod(team.getContestId()); + contestConvenience.validateVotingPeriod(contest); Optional teamVoteOptional = teamVoteRepository.findByMemberIdAndTeam(memberId, team); @@ -43,40 +45,35 @@ public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isV } private TeamVoteToggleResponse handleFirstTimeVote(Long memberId, Team team, Boolean isVoted, Contest contest) { + if (!isVoted) { + throw new TeamVoteException(NOT_VOTED_YET); + } + long currentVoteCount = countCurrentMemberVotes(memberId, team.getContestId()); int maxVotesLimit = contest.getMaxVotesLimit(); - if (isVoted) { - validateVoteLimit(currentVoteCount, maxVotesLimit); - currentVoteCount++; - } - - saveTeamVote(memberId, team, isVoted); + validateVoteLimit(currentVoteCount, maxVotesLimit); + saveTeamVote(memberId, team, true); - String message = isVoted ? "투표가 처음 등록되었습니다." : "투표가 비활성화된 상태로 초기화되었습니다."; - return TeamVoteToggleResponse.of(team.getId(), isVoted, message, currentVoteCount, maxVotesLimit); + return TeamVoteToggleResponse.of(team.getId(), true, "투표가 등록되었습니다.", currentVoteCount + 1, maxVotesLimit); } - private TeamVoteToggleResponse handleExistingVote(TeamVote teamVote, Boolean isVoted, Long memberId, Contest contest) { + private TeamVoteToggleResponse handleExistingVote(final TeamVote teamVote, final Boolean isVoted, final Long memberId, final Contest contest) { if (Objects.equals(teamVote.getIsVoted(), isVoted)) { - TeamVoteExceptionType exceptionType = isVoted ? ALREADY_VOTED : ALREADY_UNVOTED; - throw new TeamVoteException(exceptionType); + throw new TeamVoteException(isVoted ? ALREADY_VOTED : ALREADY_UNVOTED); } - long currentVoteCount = countCurrentMemberVotes(memberId, contest.getId()); - int maxVotesLimit = contest.getMaxVotesLimit(); + final long currentVoteCount = countCurrentMemberVotes(memberId, contest.getId()); + final int maxVotesLimit = contest.getMaxVotesLimit(); if (isVoted) { validateVoteLimit(currentVoteCount, maxVotesLimit); - currentVoteCount++; - } else { - currentVoteCount--; } + final long updatedVoteCount = currentVoteCount + (isVoted ? 1 : -1); teamVote.updateIsVoted(isVoted); - String message = isVoted ? "투표가 등록되었습니다." : "투표가 취소되었습니다."; - return TeamVoteToggleResponse.of(teamVote.getTeam().getId(), isVoted, message, currentVoteCount, maxVotesLimit); + return TeamVoteToggleResponse.of(teamVote.getTeam().getId(), isVoted, isVoted ? "투표가 등록되었습니다." : "투표가 취소되었습니다.", updatedVoteCount, maxVotesLimit); } private long countCurrentMemberVotes(Long memberId, Long contestId) { @@ -91,11 +88,16 @@ private void validateVoteLimit(long currentVoteCount, int maxVotesLimit) { } private void saveTeamVote(Long memberId, Team team, Boolean isVoted) { - teamVoteRepository.save(TeamVote.builder() - .memberId(memberId) - .team(team) - .isVoted(isVoted) - .build()); + try { + teamVoteRepository.save(TeamVote.builder() + .memberId(memberId) + .team(team) + .isVoted(isVoted) + .build()); + teamVoteRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new TeamVoteException(DUPLICATE_VOTE_REQUEST); + } } @Transactional(readOnly = true) diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java b/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java index 2f90fc8e..4a03d74e 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java @@ -10,6 +10,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -18,6 +20,9 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint(name = "uk_team_vote_member_team", columnNames = {"member_id", "team_id"}) +}) public class TeamVote extends BaseEntity { @Id diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java index 7c7e81c9..515f9888 100644 --- a/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamVoteExceptionType.java @@ -7,7 +7,9 @@ public enum TeamVoteExceptionType implements BaseExceptionType { ALREADY_VOTED(HttpStatus.BAD_REQUEST, "이미 투표한 팀입니다."), ALREADY_UNVOTED(HttpStatus.BAD_REQUEST, "이미 투표를 취소한 팀입니다."), - VOTE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "대회당 최대 %d개 팀만 투표할 수 있습니다."); + VOTE_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "대회당 최대 %d개 팀만 투표할 수 있습니다."), + DUPLICATE_VOTE_REQUEST(HttpStatus.CONFLICT, "이미 처리된 요청입니다."), + NOT_VOTED_YET(HttpStatus.BAD_REQUEST, "아직 투표하지 않은 팀입니다."); private final HttpStatus httpStatus; private final String message; diff --git a/src/test/java/com/opus/opus/team/TeamFixture.java b/src/test/java/com/opus/opus/team/TeamFixture.java index 7a90d5a7..0d46d8ad 100644 --- a/src/test/java/com/opus/opus/team/TeamFixture.java +++ b/src/test/java/com/opus/opus/team/TeamFixture.java @@ -8,10 +8,10 @@ public class TeamFixture { private static final Long DEFAULT_CONTEST_ID = 1L; public static Team createTeam() { - return createTeam(DEFAULT_CONTEST_ID); + return createTeamWithContestId(DEFAULT_CONTEST_ID); } - public static Team createTeam(final Long contestId) { + public static Team createTeamWithContestId(final Long contestId) { return Team.builder() .teamName("팀 옵스") .projectName("옵스 프로젝트") diff --git a/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java index d1063d24..f284535e 100644 --- a/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java +++ b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java @@ -57,7 +57,7 @@ void setUp() { newContest.updateMaxVotesLimit(2); // 최대 투표 수를 기본적으로 2로 설정 contest = contestRepository.save(newContest); - team = teamRepository.save(TeamFixture.createTeam(contest.getId())); + team = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); member = memberRepository.save(MemberFixture.createMember()); } @@ -150,7 +150,7 @@ void setUp() { notVotingContest.updateMaxVotesLimit(2); notVotingContest = contestRepository.save(notVotingContest); - Team notVotingTeam = teamRepository.save(TeamFixture.createTeam(notVotingContest.getId())); + Team notVotingTeam = teamRepository.save(TeamFixture.createTeamWithContestId(notVotingContest.getId())); assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), notVotingTeam.getId(), true)) .isInstanceOf(ContestException.class) @@ -160,8 +160,8 @@ void setUp() { @Test @DisplayName("[실패] 최대 투표 수를 초과하면 예외가 발생한다.") void 최대_투표_수를_초과하면_예외가_발생한다() { - Team secondTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); - Team thirdTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); + Team secondTeam = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); + Team thirdTeam = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); teamVoteRepository.save(TeamVoteFixture.createTeamVote(secondTeam, member.getId(), true)); @@ -195,7 +195,7 @@ void setUp() { @DisplayName("[성공] 취소한 투표는 카운트에서 제외된다.") void 취소한_투표는_카운트에서_제외된다() { teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); - Team secondTeam = teamRepository.save(TeamFixture.createTeam(contest.getId())); + Team secondTeam = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); teamVoteRepository.save(TeamVoteFixture.createTeamVote(secondTeam, member.getId(), false)); MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contest.getId()); From 2153061ab5e31eb91986cfb0cad945ba94d75c07 Mon Sep 17 00:00:00 2001 From: pykido Date: Fri, 30 Jan 2026 22:27:32 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Docs?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opus/opus/docs/asciidoc/team-vote.adoc | 24 +++++++--- .../restdocs/docs/TeamVoteApiDocsTest.java | 47 +++++++++++++++---- .../TeamVoteCommandServiceTest.java | 21 ++++----- 3 files changed, 65 insertions(+), 27 deletions(-) diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc index 95307ab2..b1643553 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-vote.adoc @@ -14,7 +14,7 @@ endif::[] link:./opus.html[API 목록으로 돌아가기] -== `PATCH`: 팀 투표 토글 +== `PUT`: 팀 투표 토글 해당 팀에 대해 투표 상태를 토글합니다. @@ -46,12 +46,12 @@ include::{snippets}/toggle-team-vote/response-fields.adoc[] |Request isVoted |응답 메시지 |HTTP 상태 코드 |true -|투표가 처음 등록되었습니다. +|투표가 등록되었습니다. |200 OK |false -|투표가 비활성화된 상태로 초기화되었습니다. -|200 OK +|아직 투표하지 않은 팀입니다. +|400 Bad Request |=== isVoted: true → 투표 등록 @@ -133,7 +133,19 @@ include::{snippets}/toggle-team-vote-fail-already-unvoted/http-response.adoc[] ==== -.❌ Case 4: 최대 투표 수 초과 +.❌ Case 4: 투표한 적 없는 팀에 취소 요청 + +[%collapsible] + +==== + +include::{snippets}/toggle-team-vote-fail-not-voted-yet/http-request.adoc[] + +include::{snippets}/toggle-team-vote-fail-not-voted-yet/http-response.adoc[] + +==== + +.❌ Case 5: 최대 투표 수 초과 [%collapsible] @@ -145,7 +157,7 @@ include::{snippets}/toggle-team-vote-fail-limit-exceeded/http-response.adoc[] ==== -.❌ Case 5: 투표 기간이 아님 +.❌ Case 6: 투표 기간이 아님 [%collapsible] diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java index 96b505e1..1999753e 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamVoteApiDocsTest.java @@ -5,6 +5,7 @@ import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.NOT_VOTED_YET; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.VOTE_LIMIT_EXCEEDED; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -13,13 +14,12 @@ import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; -import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; -import static org.springframework.restdocs.snippet.Attributes.key; import static org.springframework.test.util.ReflectionTestUtils.setField; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -54,11 +54,11 @@ void setUp() { @DisplayName("[성공] 팀에 투표를 등록한다.") void 팀에_투표를_등록한다() throws Exception { final TeamVoteToggleRequest request = new TeamVoteToggleRequest(true); - final TeamVoteToggleResponse response = new TeamVoteToggleResponse(1L, true, "투표가 처음 등록되었습니다.", 1L, 2L); + final TeamVoteToggleResponse response = new TeamVoteToggleResponse(1L, true, "투표가 등록되었습니다.", 1L, 2L); given(teamVoteCommandService.toggleVote(any(), any(), any())).willReturn(response); - mockMvc.perform(patch("/teams/{teamId}/votes", 1) + mockMvc.perform(put("/teams/{teamId}/votes", 1) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -91,7 +91,7 @@ void setUp() { given(teamVoteCommandService.toggleVote(any(), any(), any())).willReturn(response); - mockMvc.perform(patch("/teams/{teamId}/votes", 1) + mockMvc.perform(put("/teams/{teamId}/votes", 1) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -125,7 +125,7 @@ void setUp() { .given(teamVoteCommandService) .toggleVote(any(), any(), any()); - mockMvc.perform(patch("/teams/{teamId}/votes", 999) + mockMvc.perform(put("/teams/{teamId}/votes", 999) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -152,7 +152,7 @@ void setUp() { .given(teamVoteCommandService) .toggleVote(any(), any(), any()); - mockMvc.perform(patch("/teams/{teamId}/votes", 1) + mockMvc.perform(put("/teams/{teamId}/votes", 1) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -179,7 +179,7 @@ void setUp() { .given(teamVoteCommandService) .toggleVote(any(), any(), any()); - mockMvc.perform(patch("/teams/{teamId}/votes", 1) + mockMvc.perform(put("/teams/{teamId}/votes", 1) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -197,6 +197,33 @@ void setUp() { )); } + @Test + @DisplayName("[실패] 투표한 적 없는 팀에 취소 요청 시 400 에러를 반환한다.") + void 투표한_적_없는_팀에_취소_요청_시_에러를_반환한다() throws Exception { + final TeamVoteToggleRequest request = new TeamVoteToggleRequest(false); + + willThrow(new TeamVoteException(NOT_VOTED_YET)) + .given(teamVoteCommandService) + .toggleVote(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-vote-fail-not-voted-yet", + pathParameters( + parameterWithName("teamId").description("투표한 적 없는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isVoted", "투표 여부") + ) + )); + } + @Test @DisplayName("[실패] 최대 투표 수 초과 시 400 에러를 반환한다.") void 최대_투표_수_초과_시_에러를_반환한다() throws Exception { @@ -206,7 +233,7 @@ void setUp() { .given(teamVoteCommandService) .toggleVote(any(), any(), any()); - mockMvc.perform(patch("/teams/{teamId}/votes", 3) + mockMvc.perform(put("/teams/{teamId}/votes", 3) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) @@ -233,7 +260,7 @@ void setUp() { .given(teamVoteCommandService) .toggleVote(any(), any(), any()); - mockMvc.perform(patch("/teams/{teamId}/votes", 1) + mockMvc.perform(put("/teams/{teamId}/votes", 1) .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) diff --git a/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java index f284535e..ab55b8b8 100644 --- a/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java +++ b/src/test/java/com/opus/opus/team/application/TeamVoteCommandServiceTest.java @@ -4,6 +4,7 @@ import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_UNVOTED; import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.ALREADY_VOTED; +import static com.opus.opus.modules.team.exception.TeamVoteExceptionType.NOT_VOTED_YET; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -68,7 +69,7 @@ void setUp() { assertThat(response.teamId()).isEqualTo(team.getId()); assertThat(response.isVoted()).isTrue(); - assertThat(response.message()).isEqualTo("투표가 처음 등록되었습니다."); + assertThat(response.message()).isEqualTo("투표가 등록되었습니다."); assertThat(response.remainingVotesCount()).isEqualTo(1L); assertThat(response.maxVotesLimit()).isEqualTo(2L); @@ -76,16 +77,6 @@ void setUp() { assertThat(savedVote.getIsVoted()).isTrue(); } - @Test - @DisplayName("[성공] 처음 요청이 isVoted=false이면 비활성화 상태로 초기화된다.(다만, 일반적으로는 사용하지 않는 플로우임)") - void 처음_요청이_isVoted_false이면_비활성화_상태로_초기화된다() { - TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), team.getId(), false); - - assertThat(response.isVoted()).isFalse(); - assertThat(response.message()).isEqualTo("투표가 비활성화된 상태로 초기화되었습니다."); - assertThat(response.remainingVotesCount()).isEqualTo(2L); - } - @Test @DisplayName("[성공] 기존 투표를 취소할 수 있다.") void 기존_투표를_취소할_수_있다() { @@ -112,6 +103,14 @@ void setUp() { assertThat(response.remainingVotesCount()).isEqualTo(1L); } + @Test + @DisplayName("[실패] 투표한 적 없는 팀에 취소 요청하면 예외가 발생한다.") + void 투표한_적_없는_팀에_취소_요청하면_예외가_발생한다() { + assertThatThrownBy(() -> teamVoteCommandService.toggleVote(member.getId(), team.getId(), false)) + .isInstanceOf(TeamVoteException.class) + .hasMessage(NOT_VOTED_YET.errorMessage()); + } + @Test @DisplayName("[실패] 이미 투표한 팀에 다시 투표하면 예외가 발생한다.") void 이미_투표한_팀에_다시_투표하면_예외가_발생한다() { From 287b665a85cf868a1b2ab13a8e9b0ea312dd5e9b Mon Sep 17 00:00:00 2001 From: pykido Date: Sat, 7 Feb 2026 17:28:09 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor=20:=20URL=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/contest/api/ContestController.java | 13 +++++++++++++ .../opus/modules/team/api/TeamController.java | 16 ++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/java/com/opus/opus/modules/contest/api/ContestController.java b/src/main/java/com/opus/opus/modules/contest/api/ContestController.java index 90a66bec..cfc1d162 100644 --- a/src/main/java/com/opus/opus/modules/contest/api/ContestController.java +++ b/src/main/java/com/opus/opus/modules/contest/api/ContestController.java @@ -1,5 +1,6 @@ package com.opus.opus.modules.contest.api; +import com.opus.opus.global.security.annotation.LoginMember; import com.opus.opus.modules.contest.application.ContestCommandService; import com.opus.opus.modules.contest.application.ContestQueryService; import com.opus.opus.modules.contest.application.dto.request.ContestCurrentToggleRequest; @@ -11,7 +12,10 @@ import com.opus.opus.modules.contest.application.dto.response.ContestResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.TeamVoteCommandService; import com.opus.opus.modules.team.application.dto.ImageResponse; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -40,6 +44,7 @@ public class ContestController { private final ContestCommandService contestCommandService; private final ContestQueryService contestQueryService; + private final TeamVoteCommandService teamVoteCommandService; @GetMapping("/{contestId}/image/banner") public ResponseEntity getContestBanner(@PathVariable final Long contestId) { @@ -134,4 +139,12 @@ public ResponseEntity getMaxVotesLimit(@PathVariable final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contestId); return ResponseEntity.ok(response); } + + @Secured({"ROLE_회원", "ROLE_관리자"}) + @GetMapping("/{contestId}/votes/me") + public ResponseEntity getMemberVoteCount(@PathVariable Long contestId, + @LoginMember Member member) { + MemberVoteCountResponse response = teamVoteCommandService.getMemberVoteCount(member.getId(), contestId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamController.java b/src/main/java/com/opus/opus/modules/team/api/TeamController.java index 2030e5a6..a5a9e276 100644 --- a/src/main/java/com/opus/opus/modules/team/api/TeamController.java +++ b/src/main/java/com/opus/opus/modules/team/api/TeamController.java @@ -1,9 +1,14 @@ package com.opus.opus.modules.team.api; +import com.opus.opus.global.security.annotation.LoginMember; +import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.team.application.TeamCommandService; import com.opus.opus.modules.team.application.TeamQueryService; +import com.opus.opus.modules.team.application.TeamVoteCommandService; import com.opus.opus.modules.team.application.dto.ImageResponse; import com.opus.opus.modules.team.application.dto.request.PreviewDeleteRequest; +import com.opus.opus.modules.team.application.dto.request.TeamVoteToggleRequest; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; @@ -16,6 +21,7 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -30,6 +36,7 @@ public class TeamController { private final TeamQueryService teamQueryService; private final TeamCommandService teamCommandService; + private final TeamVoteCommandService teamVoteCommandService; @GetMapping("/{teamId}/image/{imageId}") public ResponseEntity getPreviewImage(@PathVariable final Long teamId, @PathVariable final Long imageId) { @@ -79,4 +86,13 @@ public ResponseEntity deleteThumbnailImage(@PathVariable final Long teamId teamCommandService.deleteThumbnailImage(teamId); return ResponseEntity.noContent().build(); } + + @Secured({"ROLE_회원", "ROLE_관리자"}) + @PutMapping("/{teamId}/votes") + public ResponseEntity toggleVote(@PathVariable Long teamId, + @RequestBody @Valid TeamVoteToggleRequest request, + @LoginMember Member member) { + TeamVoteToggleResponse response = teamVoteCommandService.toggleVote(member.getId(), teamId, request.isVoted()); + return ResponseEntity.ok(response); + } } From 8157e2091999111391478a72aa39bda9587cd8a4 Mon Sep 17 00:00:00 2001 From: pykido Date: Sat, 7 Feb 2026 17:33:03 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor=20:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=9C=A0=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/schema.sql | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index bb6392f7..6cf4b0b7 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -187,5 +187,6 @@ CREATE TABLE `team_vote` ( `is_voted` bit(1) NOT NULL, `member_id` bigint NOT NULL, `team_id` bigint NOT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_vote_member_team` (`member_id`, `team_id`) );