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 1ba8d59..7aa282e 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc @@ -13,7 +13,6 @@ endif::[] link:./member.html[회원 API] == 팀 관련 API - link:./team.html[팀 API] link:./team-comment.html[팀 댓글 API] @@ -21,6 +20,8 @@ link:./team-vote.html[팀 투표 API] link:./team-member.html[팀원 API] +link:./team-like.html[팀 좋아요 API] + == 공지 관련 API link:./notice.html[공지 API] diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc new file mode 100644 index 0000000..43d3a97 --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc @@ -0,0 +1,140 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += TEAM LIKE API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:./opus.html[API 목록으로 돌아가기] + +== `PUT`: 팀 좋아요 토글 + +해당 팀에 대해 좋아요(찜) 상태를 토글합니다. + +* `isLiked: true` → 좋아요 등록 +* `isLiked: false` → 좋아요 취소 + +NOTE: 좋아요를 통해 팀을 찜할 수 있습니다. + +NOTE: 투표 기간에는 투표만 가능하고, 투표 기간이 아닐 때만 좋아요가 가능합니다. + +.Path Parameters +include::{snippets}/toggle-team-like/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/toggle-team-like/request-headers.adoc[] + +.HTTP Request +include::{snippets}/toggle-team-like/http-request.adoc[] + +.HTTP Response +include::{snippets}/toggle-team-like/http-response.adoc[] + +.Request Fields +include::{snippets}/toggle-team-like/request-fields.adoc[] + +.Response Fields +include::{snippets}/toggle-team-like/response-fields.adoc[] + +=== 시나리오별 응답 + +==== 시나리오 1: TeamLike 데이터가 없는 경우 + +특정 멤버가 특정 팀에 대해 좋아요 API를 처음 호출하면, TeamLike 테이블에 데이터가 새로 생성됩니다. + +[cols="1,2,1"] +|=== +|Request isLiked |응답 메시지 |HTTP 상태 코드 + +|true +|좋아요가 등록되었습니다. +|200 OK + +|false +|아직 좋아요하지 않은 팀입니다. +|400 Bad Request +|=== + +==== 시나리오 2: TeamLike 데이터가 있는 경우 + +이미 해당 팀에 대한 좋아요 기록이 있는 경우, 상태에 따라 토글됩니다. + +[cols="1,1,2,1"] +|=== +|현재 isLiked |Request isLiked |응답 메시지 |HTTP 상태 코드 + +|true +|true +|이미 좋아요한 팀입니다. +|400 Bad Request + +|true +|false +|좋아요가 취소되었습니다. +|200 OK + +|false +|true +|좋아요가 등록되었습니다. +|200 OK + +|false +|false +|이미 좋아요를 취소한 팀입니다. +|400 Bad Request +|=== + + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 팀 + +[%collapsible] +==== +include::{snippets}/toggle-team-like-fail-not-found/http-request.adoc[] + +include::{snippets}/toggle-team-like-fail-not-found/http-response.adoc[] +==== + +.❌ Case 2: 이미 좋아요한 팀 + +[%collapsible] +==== +include::{snippets}/toggle-team-like-fail-already-liked/http-request.adoc[] + +include::{snippets}/toggle-team-like-fail-already-liked/http-response.adoc[] +==== + +.❌ Case 3: 이미 좋아요 취소한 팀 + +[%collapsible] +==== +include::{snippets}/toggle-team-like-fail-already-unliked/http-request.adoc[] + +include::{snippets}/toggle-team-like-fail-already-unliked/http-response.adoc[] +==== + +.❌ Case 4: 좋아요한 적 없는 팀에 취소 요청 + +[%collapsible] +==== +include::{snippets}/toggle-team-like-fail-not-liked-yet/http-request.adoc[] + +include::{snippets}/toggle-team-like-fail-not-liked-yet/http-response.adoc[] +==== + +.❌ Case 5: 투표 기간 중 좋아요 요청 + +[%collapsible] +==== +include::{snippets}/toggle-team-like-fail-voting-period/http-request.adoc[] + +include::{snippets}/toggle-team-like-fail-voting-period/http-response.adoc[] +==== diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java index d67655d..992ba30 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java @@ -4,7 +4,6 @@ import static com.opus.opus.modules.contest.exception.ContestExceptionType.*; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED; import static com.opus.opus.modules.file.domain.FileImageType.BANNER; import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST; @@ -17,12 +16,10 @@ import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse; import com.opus.opus.modules.contest.application.dto.response.ContestResponse; -import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.ContestCategory; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.exception.ContestException; -import com.opus.opus.modules.contest.exception.ContestExceptionType; import com.opus.opus.modules.file.domain.File; import com.opus.opus.modules.file.domain.dao.FileRepository; import com.opus.opus.modules.file.exception.FileException; @@ -122,17 +119,11 @@ private void checkVoteRange(final VoteUpdateRequest voteRequest) { public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimit) { final Contest contest = contestConvenience.getValidateExistContest(contestId); - validateNotInVotingPeriod(contest); + contestConvenience.validateNotInVotingPeriod(contest); contest.updateMaxVotesLimit(maxVotesLimit); } - private void validateNotInVotingPeriod(final Contest contest) { - if (contest.isVotingPeriod()) { - throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD); - } - } - private void checkWebpConverted(File existingFile) { if (!existingFile.getIsWebpConverted()) { throw new FileException(NOT_WEBP_CONVERTED); 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 9e154dc..40b6122 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 @@ -1,6 +1,7 @@ package com.opus.opus.modules.contest.application.convenience; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; 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; @@ -49,6 +50,12 @@ public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } + public void validateNotInVotingPeriod(final Contest contest) { + if (contest.isVotingPeriod()) { + throw new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD); + } + } + 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/contest/exception/ContestExceptionType.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java index 1a23b23..b6b1c02 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,6 +12,7 @@ public enum ContestExceptionType implements BaseExceptionType { CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."), CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."), VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."), + NOT_ALLOWED_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "현재 투표 기간이므로 해당 작업을 수행할 수 없습니다."), CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다."), NOT_VOTE_PERIOD_NOW(HttpStatus.BAD_REQUEST, "지금은 투표 기간이 아닙니다."), ; 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 34012d5..1f290c2 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 @@ -6,6 +6,8 @@ import com.opus.opus.modules.team.application.TeamQueryService; 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.TeamLikeToggleRequest; +import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse; import com.opus.opus.modules.team.application.dto.request.TeamVoteToggleRequest; import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; import jakarta.validation.Valid; @@ -85,15 +87,6 @@ public ResponseEntity deleteThumbnailImage(@PathVariable final Long 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 = teamCommandService.toggleVote(member.getId(), teamId, request.isVoted()); - return ResponseEntity.ok(response); - } - @GetMapping("/{teamId}/image/posters") public ResponseEntity getPosterImage(@PathVariable final Long teamId) { final ImageResponse imageResponse = teamQueryService.getPosterImage(teamId); @@ -117,4 +110,22 @@ public ResponseEntity deletePosterImage(@PathVariable final Long teamId) { teamCommandService.deletePosterImage(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 = teamCommandService.toggleVote(member.getId(), teamId, request.isVoted()); + return ResponseEntity.ok(response); + } + + @Secured({"ROLE_회원", "ROLE_관리자"}) + @PutMapping("/{teamId}/likes") + public ResponseEntity toggleLike(@PathVariable final Long teamId, + @RequestBody @Valid final TeamLikeToggleRequest request, + @LoginMember final Member member) { + final TeamLikeToggleResponse response = teamCommandService.toggleLike(member.getId(), teamId, request.isLiked()); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java index 5ca6f10..95abf93 100644 --- a/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java +++ b/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java @@ -5,7 +5,9 @@ import static com.opus.opus.modules.file.domain.FileImageType.THUMBNAIL; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; import static com.opus.opus.modules.file.exception.FileExceptionType.EXCEED_PREVIEW_LIMIT; -import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_WEBP_CONVERTED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_LIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_UNLIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET; 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; @@ -15,13 +17,16 @@ import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.modules.contest.application.convenience.ContestConvenience; import com.opus.opus.modules.contest.domain.Contest; -import com.opus.opus.modules.file.domain.File; import com.opus.opus.modules.file.domain.FileImageType; import com.opus.opus.modules.file.domain.dao.FileRepository; import com.opus.opus.modules.file.exception.FileException; import com.opus.opus.modules.team.application.convenience.TeamConvenience; -import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse; import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamLike; +import com.opus.opus.modules.team.domain.dao.TeamLikeRepository; +import com.opus.opus.modules.team.exception.TeamLikeException; +import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; 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; @@ -47,6 +52,8 @@ public class TeamCommandService { private final TeamVoteRepository teamVoteRepository; + private final TeamLikeRepository teamLikeRepository; + public void savePreviewImages(final Long teamId, final List images) { teamConvenience.validateExistTeam(teamId); @@ -83,6 +90,18 @@ public void deletePosterImage(final Long teamId) { deleteIfExists(teamId, POSTER); } + public TeamLikeToggleResponse toggleLike(final Long memberId, final Long teamId, final Boolean isLiked) { + final Team team = teamConvenience.getValidateExistTeam(teamId); + final Contest contest = contestConvenience.getValidateExistContest(team.getContestId()); + + contestConvenience.validateNotInVotingPeriod(contest); + + final Optional teamLikeOptional = teamLikeRepository.findByMemberIdAndTeam(memberId, team); + return teamLikeOptional + .map(teamLike -> handleExistingLike(teamLike, isLiked)) + .orElseGet(() -> handleFirstTimeLike(memberId, team, isLiked)); + } + public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isVoted) { Team team = teamConvenience.getValidateExistTeam(teamId); Contest contest = contestConvenience.getValidateExistContest(team.getContestId()); @@ -95,6 +114,34 @@ public TeamVoteToggleResponse toggleVote(Long memberId, Long teamId, Boolean isV .orElseGet(() -> handleFirstTimeVote(memberId, team, isVoted, contest)); } + + private TeamLikeToggleResponse handleFirstTimeLike(final Long memberId, final Team team, final Boolean isLiked) { + if (!isLiked) { + throw new TeamLikeException(NOT_LIKED_YET); + } + + saveTeamLike(memberId, team); + return TeamLikeToggleResponse.of(team.getId(), true, "좋아요가 등록되었습니다."); + } + + private TeamLikeToggleResponse handleExistingLike(final TeamLike teamLike, final Boolean isLiked) { + if (Objects.equals(teamLike.getIsLiked(), isLiked)) { + throw new TeamLikeException(isLiked ? ALREADY_LIKED : ALREADY_UNLIKED); + } + + teamLike.updateIsLiked(isLiked); + + return TeamLikeToggleResponse.of(teamLike.getTeam().getId(), isLiked, isLiked ? "좋아요가 등록되었습니다." : "좋아요가 취소되었습니다."); + } + + private void saveTeamLike(final Long memberId, final Team team) { + teamLikeRepository.save(TeamLike.builder() + .memberId(memberId) + .team(team) + .isLiked(true) + .build()); + } + private TeamVoteToggleResponse handleFirstTimeVote(Long memberId, Team team, Boolean isVoted, Contest contest) { if (!isVoted) { throw new TeamVoteException(NOT_VOTED_YET); diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamLikeToggleRequest.java b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamLikeToggleRequest.java new file mode 100644 index 0000000..04e245a --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamLikeToggleRequest.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.team.application.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record TeamLikeToggleRequest( + @NotNull(message = "isLiked 값은 필수입니다.") + Boolean isLiked +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamLikeToggleResponse.java b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamLikeToggleResponse.java new file mode 100644 index 0000000..1c6601d --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamLikeToggleResponse.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.team.application.dto.response; + +public record TeamLikeToggleResponse( + Long teamId, + Boolean isLiked, + String message +) { + public static TeamLikeToggleResponse of(Long teamId, Boolean isLiked, String message) { + return new TeamLikeToggleResponse(teamId, isLiked, message); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamLike.java b/src/main/java/com/opus/opus/modules/team/domain/TeamLike.java index 1f116df..71889ec 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/TeamLike.java +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamLike.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_like_member_team", columnNames = {"member_id", "team_id"}) +}) public class TeamLike extends BaseEntity { @Id @@ -41,4 +46,7 @@ public TeamLike(final Long memberId, final Team team, final Boolean isLiked) { this.isLiked = isLiked; } + public void updateIsLiked(final Boolean isLiked) { + this.isLiked = isLiked; + } } diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamLikeRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamLikeRepository.java new file mode 100644 index 0000000..0ea4a37 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamLikeRepository.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.team.domain.dao; + +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamLike; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamLikeRepository extends JpaRepository { + + Optional findByMemberIdAndTeam(Long memberId, Team team); +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamLikeException.java b/src/main/java/com/opus/opus/modules/team/exception/TeamLikeException.java new file mode 100644 index 0000000..49bb4e7 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamLikeException.java @@ -0,0 +1,19 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class TeamLikeException extends BaseException { + + private final TeamLikeExceptionType exceptionType; + + public TeamLikeException(final TeamLikeExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamLikeExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamLikeExceptionType.java new file mode 100644 index 0000000..8687cdb --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamLikeExceptionType.java @@ -0,0 +1,30 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum TeamLikeExceptionType implements BaseExceptionType { + + ALREADY_LIKED(HttpStatus.BAD_REQUEST, "이미 좋아요한 팀입니다."), + ALREADY_UNLIKED(HttpStatus.BAD_REQUEST, "이미 좋아요를 취소한 팀입니다."), + NOT_LIKED_YET(HttpStatus.BAD_REQUEST, "아직 좋아요하지 않은 팀입니다."), + DUPLICATE_LIKE_REQUEST(HttpStatus.CONFLICT, "이미 처리된 요청입니다."); + + private final HttpStatus httpStatus; + private final String message; + + TeamLikeExceptionType(final HttpStatus httpStatus, final String message) { + this.httpStatus = httpStatus; + this.message = message; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return message; + } +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6cf4b0b..0a13935 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -152,7 +152,8 @@ CREATE TABLE `team_like` ( `is_liked` bit(1) NOT NULL, `member_id` bigint NOT NULL, `team_id` bigint NOT NULL, - PRIMARY KEY (`id`) + PRIMARY KEY (`id`), + UNIQUE KEY `uk_team_like_member_team` (`member_id`, `team_id`) ); CREATE TABLE `team_member` ( diff --git a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java index 6272572..c2181a9 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -1,6 +1,6 @@ package com.opus.opus.contest.application; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.VOTE_END_PRECEDE_VOTE_START; import static org.assertj.core.api.Assertions.assertThat; @@ -98,7 +98,7 @@ void setUp() { assertThatThrownBy(() -> { contestCommandService.updateMaxVotesLimit(contest.getId(), MAX_VOTES_LIMIT); }).isInstanceOf(ContestException.class) - .hasMessage(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD.errorMessage()); + .hasMessage(NOT_ALLOWED_DURING_VOTING_PERIOD.errorMessage()); } @Test @@ -125,4 +125,4 @@ void setUp() { final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); // 변경 후 값 검증 assertThat(updatedContest.getMaxVotesLimit()).isEqualTo(MAX_VOTES_LIMIT); } -} \ No newline at end of file +} diff --git a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java index 8dd1cd0..e96a4a6 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -1,6 +1,6 @@ package com.opus.opus.restdocs.docs; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; 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 java.time.LocalDateTime.now; @@ -232,7 +232,7 @@ void setUp() { void 투표_진행_중_최대_투표_개수_변경_시_에러를_반환한다() throws Exception { final ContestVotesLimitRequest request = new ContestVotesLimitRequest(2); - willThrow(new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD)) + willThrow(new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD)) .given(contestCommandService) .updateMaxVotesLimit(any(), any()); diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamLikeApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamLikeApiDocsTest.java new file mode 100644 index 0000000..8345080 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamLikeApiDocsTest.java @@ -0,0 +1,244 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_LIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_UNLIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET; +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.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.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.TeamLikeToggleRequest; +import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.modules.team.exception.TeamLikeException; +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 TeamLikeApiDocsTest extends RestDocsTest { + + private static final String MEMBER_TOKEN = "Bearer member.access.token"; + + private Member member; + + @BeforeEach + void setUp() { + member = MemberFixture.createMember(); + setField(member, "id", 1L); + } + + @Test + @DisplayName("[성공] 팀에 좋아요를 등록한다.") + void 팀에_좋아요를_등록한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(true); + final TeamLikeToggleResponse response = new TeamLikeToggleResponse(1L, true, "좋아요가 등록되었습니다."); + + given(teamCommandService.toggleLike(any(), any(), any())).willReturn(response); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("toggle-team-like", + pathParameters( + parameterWithName("teamId").description("좋아요할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부 (true: 등록, false: 취소)") + ), + responseFields( + numberFieldWithPath("teamId", "팀 ID"), + booleanFieldWithPath("isLiked", "좋아요 상태"), + stringFieldWithPath("message", "응답 메시지") + ) + )); + } + + @Test + @DisplayName("[성공] 팀 좋아요를 취소한다.") + void 팀_좋아요를_취소한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(false); + final TeamLikeToggleResponse response = new TeamLikeToggleResponse(1L, false, "좋아요가 취소되었습니다."); + + given(teamCommandService.toggleLike(any(), any(), any())).willReturn(response); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("cancel-team-like", + pathParameters( + parameterWithName("teamId").description("좋아요를 취소할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부 (true: 등록, false: 취소)") + ), + responseFields( + numberFieldWithPath("teamId", "팀 ID"), + booleanFieldWithPath("isLiked", "좋아요 상태"), + stringFieldWithPath("message", "응답 메시지") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에 좋아요 시 404 에러를 반환한다.") + void 존재하지_않는_팀에_좋아요_시_에러를_반환한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(true); + + willThrow(new TeamException(NOT_FOUND_TEAM)) + .given(teamCommandService) + .toggleLike(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/likes", 999) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andDo(document("toggle-team-like-fail-not-found", + pathParameters( + parameterWithName("teamId").description("존재하지 않는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 이미 좋아요한 팀에 다시 좋아요 시 400 에러를 반환한다.") + void 이미_좋아요한_팀에_다시_좋아요_시_에러를_반환한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(true); + + willThrow(new TeamLikeException(ALREADY_LIKED)) + .given(teamCommandService) + .toggleLike(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-like-fail-already-liked", + pathParameters( + parameterWithName("teamId").description("이미 좋아요한 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 이미 좋아요 취소한 팀에 다시 취소 시 400 에러를 반환한다.") + void 이미_좋아요_취소한_팀에_다시_취소_시_에러를_반환한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(false); + + willThrow(new TeamLikeException(ALREADY_UNLIKED)) + .given(teamCommandService) + .toggleLike(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-like-fail-already-unliked", + pathParameters( + parameterWithName("teamId").description("이미 좋아요 취소한 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 좋아요한 적 없는 팀에 취소 요청 시 400 에러를 반환한다.") + void 좋아요한_적_없는_팀에_취소_요청_시_에러를_반환한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(false); + + willThrow(new TeamLikeException(NOT_LIKED_YET)) + .given(teamCommandService) + .toggleLike(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-like-fail-not-liked-yet", + pathParameters( + parameterWithName("teamId").description("좋아요한 적 없는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 투표 기간에 좋아요 시 400 에러를 반환한다.") + void 투표_기간에_좋아요_시_에러를_반환한다() throws Exception { + final TeamLikeToggleRequest request = new TeamLikeToggleRequest(true); + + willThrow(new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD)) + .given(teamCommandService) + .toggleLike(any(), any(), any()); + + mockMvc.perform(put("/teams/{teamId}/likes", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("toggle-team-like-fail-voting-period", + pathParameters( + parameterWithName("teamId").description("좋아요하려는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + booleanFieldWithPath("isLiked", "좋아요 여부") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/team/TeamLikeFixture.java b/src/test/java/com/opus/opus/team/TeamLikeFixture.java new file mode 100644 index 0000000..33bc78c --- /dev/null +++ b/src/test/java/com/opus/opus/team/TeamLikeFixture.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.TeamLike; + +public class TeamLikeFixture { + + public static TeamLike createTeamLike(final Team team, final Long memberId, final Boolean isLiked) { + return TeamLike.builder() + .team(team) + .memberId(memberId) + .isLiked(isLiked) + .build(); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java index 389234d..cf50a4b 100644 --- a/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java +++ b/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java @@ -1,13 +1,17 @@ package com.opus.opus.team.application; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; import static com.opus.opus.modules.file.domain.FileImageType.POSTER; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_LIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.ALREADY_UNLIKED; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET; +import static org.assertj.core.api.Assertions.assertThat; 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; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -17,7 +21,6 @@ import static org.springframework.test.util.ReflectionTestUtils.setField; import com.opus.opus.contest.ContestFixture; -import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.helper.IntegrationTest; import com.opus.opus.member.MemberFixture; import com.opus.opus.modules.contest.domain.Contest; @@ -29,23 +32,26 @@ import com.opus.opus.modules.member.domain.dao.MemberRepository; import com.opus.opus.modules.team.application.TeamCommandService; import com.opus.opus.modules.team.application.dto.response.TeamVoteToggleResponse; +import com.opus.opus.modules.team.application.dto.response.TeamLikeToggleResponse; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.TeamVote; +import com.opus.opus.modules.team.domain.TeamLike; +import com.opus.opus.modules.team.domain.dao.TeamLikeRepository; 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.modules.team.exception.TeamLikeException; import com.opus.opus.team.FileFixture; import com.opus.opus.team.TeamFixture; import com.opus.opus.team.TeamVoteFixture; import java.time.LocalDateTime; -import java.util.ArrayList; +import com.opus.opus.team.TeamLikeFixture; 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; import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.bean.override.mockito.MockitoBean; public class TeamCommandServiceTest extends IntegrationTest { @@ -61,43 +67,52 @@ public class TeamCommandServiceTest extends IntegrationTest { @Autowired private ContestRepository contestRepository; @Autowired + private TeamLikeRepository teamLikeRepository; + @Autowired private FileRepository fileRepository; - private Contest contest; - private Team team; + private Contest notVotingContest; + private Contest votingContest; + private Team notVotingTeam; + private Team votingTeam; + private Team generalTeam; private Member member; @BeforeEach void setUp() { - Contest newContest = ContestFixture.createContest(); - newContest.updateVotePeriod(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); - newContest.updateMaxVotesLimit(2); - contest = contestRepository.save(newContest); - - team = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); + generalTeam = teamRepository.save(TeamFixture.createTeam()); member = memberRepository.save(MemberFixture.createMember()); + + final Contest newNotVotingContest = ContestFixture.createContest(); + newNotVotingContest.updateVotePeriod(LocalDateTime.now().minusDays(10), LocalDateTime.now().minusDays(5)); + notVotingContest = contestRepository.save(newNotVotingContest); // 투표 기간이 지난 대회 생성 + notVotingTeam = teamRepository.save(TeamFixture.createTeamWithContestId(notVotingContest.getId())); + + final Contest newVotingContest = ContestFixture.createContest(); + newVotingContest.updateVotePeriod(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(5)); + newVotingContest.updateMaxVotesLimit(2); + votingContest = contestRepository.save(newVotingContest); // 투표 가능한 대회 생성 + votingTeam = teamRepository.save(TeamFixture.createTeamWithContestId(votingContest.getId())); // 투표 가능한 팀 생성 } @Test @DisplayName("[성공] 팀 포스터 이미지를 저장한다.") void 팀_포스터_이미지를_저장한다() { // given - final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", - "content".getBytes()); + final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", "content".getBytes()); // when - teamCommandService.savePosterImage(team.getId(), image); + teamCommandService.savePosterImage(generalTeam.getId(), image); // then - verify(fileStorageUtil, times(1)).storeFile(any(), eq(team.getId()), eq(TEAM), eq(POSTER)); + verify(fileStorageUtil, times(1)).storeFile(any(), eq(generalTeam.getId()), eq(TEAM), eq(POSTER)); } @Test @DisplayName("[실패] 팀이 존재하지 않으면 포스터 이미지를 저장할 수 없다.") void 팀이_존재하지_않으면_포스터_이미지를_저장할_수_없다() { // given - final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", - "content".getBytes()); + final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", "content".getBytes()); final long notExistTeamId = 999L; // when & then @@ -111,13 +126,13 @@ void setUp() { void 팀_포스터_이미지를_삭제한다() { // given final File file = FileFixture.createTeamPosterFile(); - setField(file, "referenceId", team.getId()); + setField(file, "referenceId", generalTeam.getId()); final File savedFile = fileRepository.save(file); savedFile.updateIsWebpConverted(true); fileRepository.saveAndFlush(savedFile); // when - teamCommandService.deletePosterImage(team.getId()); + teamCommandService.deletePosterImage(generalTeam.getId()); // then verify(fileStorageUtil, times(1)).deleteFile(savedFile.getId()); @@ -127,7 +142,7 @@ void setUp() { @DisplayName("[성공] 팀 포스터 이미지가 없어도 삭제 요청 시 예외가 발생하지 않는다.") void 팀_포스터_이미지가_없어도_삭제_요청_시_예외가_발생하지_않는다() { // when - teamCommandService.deletePosterImage(team.getId()); + teamCommandService.deletePosterImage(generalTeam.getId()); // then verify(fileStorageUtil, never()).deleteFile(any()); @@ -138,41 +153,40 @@ void setUp() { void 팀_포스터_이미지가_이미_존재하면_기존_이미지를_삭제하고_새로_저장한다() { // given final File existingFile = FileFixture.createTeamPosterFile(); - setField(existingFile, "referenceId", team.getId()); + setField(existingFile, "referenceId", generalTeam.getId()); final File savedFile = fileRepository.save(existingFile); - final MockMultipartFile newImage = new MockMultipartFile("image", "new_poster.jpg", "image/jpeg", - "new_content".getBytes()); + final MockMultipartFile newImage = new MockMultipartFile("image", "new_poster.jpg", "image/jpeg", "new_content".getBytes()); // when - teamCommandService.savePosterImage(team.getId(), newImage); + teamCommandService.savePosterImage(generalTeam.getId(), newImage); // then verify(fileStorageUtil, times(1)).deleteFile(savedFile.getId()); - verify(fileStorageUtil, times(1)).storeFile(any(), eq(team.getId()), eq(TEAM), eq(POSTER)); + verify(fileStorageUtil, times(1)).storeFile(any(), eq(generalTeam.getId()), eq(TEAM), eq(POSTER)); } @Test @DisplayName("[성공] 처음 투표하면 TeamVote가 생성되고 투표가 등록된다.") void 처음_투표하면_TeamVote가_생성되고_투표가_등록된다() { - TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), team.getId(), true); + TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), votingTeam.getId(), true); - assertThat(response.teamId()).isEqualTo(team.getId()); + assertThat(response.teamId()).isEqualTo(votingTeam.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(); + TeamVote savedVote = teamVoteRepository.findByMemberIdAndTeam(member.getId(), votingTeam).orElseThrow(); assertThat(savedVote.getIsVoted()).isTrue(); } @Test @DisplayName("[성공] 기존 투표를 취소할 수 있다.") void 기존_투표를_취소할_수_있다() { - teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(votingTeam, member.getId(), true)); - TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), team.getId(), false); + TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), votingTeam.getId(), false); assertThat(response.isVoted()).isFalse(); assertThat(response.message()).isEqualTo("투표가 취소되었습니다."); @@ -182,9 +196,9 @@ void setUp() { @Test @DisplayName("[성공] 취소한 투표를 다시 등록할 수 있다.") void 취소한_투표를_다시_등록할_수_있다() { - teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), false)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(votingTeam, member.getId(), false)); - TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), team.getId(), true); + TeamVoteToggleResponse response = teamCommandService.toggleVote(member.getId(), votingTeam.getId(), true); assertThat(response.isVoted()).isTrue(); assertThat(response.message()).isEqualTo("투표가 등록되었습니다."); @@ -194,7 +208,7 @@ void setUp() { @Test @DisplayName("[실패] 투표한 적 없는 팀에 취소 요청하면 예외가 발생한다.") void 투표한_적_없는_팀에_취소_요청하면_예외가_발생한다() { - assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), team.getId(), false)) + assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), votingTeam.getId(), false)) .isInstanceOf(TeamVoteException.class) .hasMessage(NOT_VOTED_YET.errorMessage()); } @@ -202,9 +216,9 @@ void setUp() { @Test @DisplayName("[실패] 이미 투표한 팀에 다시 투표하면 예외가 발생한다.") void 이미_투표한_팀에_다시_투표하면_예외가_발생한다() { - teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(votingTeam, member.getId(), true)); - assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), team.getId(), true)) + assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), votingTeam.getId(), true)) .isInstanceOf(TeamVoteException.class) .hasMessage(ALREADY_VOTED.errorMessage()); } @@ -212,9 +226,9 @@ void setUp() { @Test @DisplayName("[실패] 이미 투표 취소한 팀에 다시 취소하면 예외가 발생한다.") void 이미_투표_취소한_팀에_다시_취소하면_예외가_발생한다() { - teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), false)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(votingTeam, member.getId(), false)); - assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), team.getId(), false)) + assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), votingTeam.getId(), false)) .isInstanceOf(TeamVoteException.class) .hasMessage(ALREADY_UNVOTED.errorMessage()); } @@ -247,14 +261,101 @@ void setUp() { @Test @DisplayName("[실패] 최대 투표 수를 초과하면 예외가 발생한다.") void 최대_투표_수를_초과하면_예외가_발생한다() { - Team secondTeam = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); - Team thirdTeam = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); + Team secondTeam = teamRepository.save(TeamFixture.createTeamWithContestId(votingContest.getId())); + Team thirdTeam = teamRepository.save(TeamFixture.createTeamWithContestId(votingContest.getId())); - teamVoteRepository.save(TeamVoteFixture.createTeamVote(team, member.getId(), true)); + teamVoteRepository.save(TeamVoteFixture.createTeamVote(votingTeam, member.getId(), true)); teamVoteRepository.save(TeamVoteFixture.createTeamVote(secondTeam, member.getId(), true)); assertThatThrownBy(() -> teamCommandService.toggleVote(member.getId(), thirdTeam.getId(), true)) .isInstanceOf(TeamVoteException.class) .hasMessageContaining("최대 2개 팀만 투표할 수 있습니다."); } + + @Test + @DisplayName("[성공] 처음 좋아요하면 TeamLike가 생성되고 좋아요가 등록된다.") + void 처음_좋아요하면_TeamLike가_생성되고_좋아요가_등록된다() { + final TeamLikeToggleResponse response = teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), true); + + assertThat(response.teamId()).isEqualTo(notVotingTeam.getId()); + assertThat(response.isLiked()).isTrue(); + assertThat(response.message()).isEqualTo("좋아요가 등록되었습니다."); + + final TeamLike savedLike = teamLikeRepository.findByMemberIdAndTeam(member.getId(), notVotingTeam).orElseThrow(); + assertThat(savedLike.getIsLiked()).isTrue(); + } + + @Test + @DisplayName("[성공] 기존 좋아요를 취소할 수 있다.") + void 기존_좋아요를_취소할_수_있다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(notVotingTeam, member.getId(), true)); + + final TeamLikeToggleResponse response = teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), false); + + assertThat(response.isLiked()).isFalse(); + assertThat(response.message()).isEqualTo("좋아요가 취소되었습니다."); + } + + @Test + @DisplayName("[성공] 취소한 좋아요를 다시 등록할 수 있다.") + void 취소한_좋아요를_다시_등록할_수_있다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(notVotingTeam, member.getId(), false)); + + final TeamLikeToggleResponse response = teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), true); + + assertThat(response.isLiked()).isTrue(); + assertThat(response.message()).isEqualTo("좋아요가 등록되었습니다."); + } + + @Test + @DisplayName("[실패] 좋아요한 적 없는 팀에 취소 요청하면 예외가 발생한다.") + void 좋아요한_적_없는_팀에_취소_요청하면_예외가_발생한다() { + assertThatThrownBy(() -> teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), false)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(NOT_LIKED_YET.errorMessage()); + } + + @Test + @DisplayName("[실패] 이미 좋아요한 팀에 다시 좋아요하면 예외가 발생한다.") + void 이미_좋아요한_팀에_다시_좋아요하면_예외가_발생한다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(notVotingTeam, member.getId(), true)); + + assertThatThrownBy(() -> teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), true)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(ALREADY_LIKED.errorMessage()); + } + + @Test + @DisplayName("[실패] 이미 좋아요 취소한 팀에 다시 취소하면 예외가 발생한다.") + void 이미_좋아요_취소한_팀에_다시_취소하면_예외가_발생한다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(notVotingTeam, member.getId(), false)); + + assertThatThrownBy(() -> teamCommandService.toggleLike(member.getId(), notVotingTeam.getId(), false)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(ALREADY_UNLIKED.errorMessage()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에는 좋아요할 수 없다.") + void 존재하지_않는_팀에는_좋아요할_수_없다() { + final Long invalidTeamId = 999L; + + assertThatThrownBy(() -> teamCommandService.toggleLike(member.getId(), invalidTeamId, true)) + .isInstanceOf(TeamException.class) + .hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[실패] 투표 기간에는 좋아요할 수 없다.") + void 투표_기간에는_좋아요할_수_없다() { + final Contest votingContest = ContestFixture.createContest(); + votingContest.updateVotePeriod(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); + final Contest savedVotingContest = contestRepository.save(votingContest); + + final Team votingTeam = teamRepository.save(TeamFixture.createTeamWithContestId(savedVotingContest.getId())); + + assertThatThrownBy(() -> teamCommandService.toggleLike(member.getId(), votingTeam.getId(), true)) + .isInstanceOf(ContestException.class) + .hasMessage(NOT_ALLOWED_DURING_VOTING_PERIOD.errorMessage()); + } }