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 438881d..276ce20 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc @@ -13,13 +13,14 @@ endif::[] link:./member.html[회원 API] == 팀 관련 API - link:./team.html[팀 API] link:./team-comment.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..e42c4b0 --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-like.adoc @@ -0,0 +1,159 @@ +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[] + +.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 +|=== + +isLiked: true → 좋아요 등록 + +include::{snippets}/toggle-team-like/http-request.adoc[] + +include::{snippets}/toggle-team-like/http-response.adoc[] + +=== 시나리오 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 +|=== + +좋아요 취소 (isLiked: true → false) + +include::{snippets}/cancel-team-like/http-request.adoc[] + +include::{snippets}/cancel-team-like/http-response.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ 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 627bbe0..0726c0a 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; @@ -47,4 +48,10 @@ public long countCurrentContests() { public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } + + public void validateNotInVotingPeriod(final Contest contest) { + if (contest.isVotingPeriod()) { + throw new ContestException(NOT_ALLOWED_DURING_VOTING_PERIOD); + } + } } 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 3cc966f..8ff5020 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,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, "투표 종료가 투표 시작보다 빠를 수 없습니다."), - CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.") + NOT_ALLOWED_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "현재 투표 기간이므로 해당 작업을 수행할 수 없습니다.") ; private final HttpStatus httpStatus; 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 0d586ae..2374fa4 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.TeamLikeCommandService; 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 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 TeamLikeCommandService teamLikeCommandService; @GetMapping("/{teamId}/image/{imageId}") public ResponseEntity getPreviewImage(@PathVariable final Long teamId, @PathVariable final Long imageId) { @@ -103,4 +110,13 @@ public ResponseEntity deletePosterImage(@PathVariable final Long teamId) { teamCommandService.deletePosterImage(teamId); return ResponseEntity.noContent().build(); } + + @Secured({"ROLE_회원", "ROLE_관리자"}) + @PutMapping("/{teamId}/likes") + public ResponseEntity toggleLike(@PathVariable Long teamId, + @RequestBody @Valid TeamLikeToggleRequest request, + @LoginMember Member member) { + TeamLikeToggleResponse response = teamLikeCommandService.toggleLike(member.getId(), teamId, request.isLiked()); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamLikeCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamLikeCommandService.java new file mode 100644 index 0000000..220b9cb --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/TeamLikeCommandService.java @@ -0,0 +1,76 @@ +package com.opus.opus.modules.team.application; + +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.DUPLICATE_LIKE_REQUEST; +import static com.opus.opus.modules.team.exception.TeamLikeExceptionType.NOT_LIKED_YET; + +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.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 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; + +@Service +@RequiredArgsConstructor +@Transactional +public class TeamLikeCommandService { + + private final TeamConvenience teamConvenience; + private final ContestConvenience contestConvenience; + + private final TeamLikeRepository teamLikeRepository; + + public TeamLikeToggleResponse toggleLike(Long memberId, Long teamId, Boolean isLiked) { + Team team = teamConvenience.getValidateExistTeam(teamId); + Contest contest = contestConvenience.getValidateExistContest(team.getContestId()); + + contestConvenience.validateNotInVotingPeriod(contest); + + Optional teamLikeOptional = teamLikeRepository.findByMemberIdAndTeam(memberId, team); + return teamLikeOptional + .map(teamLike -> handleExistingLike(teamLike, isLiked)) + .orElseGet(() -> handleFirstTimeLike(memberId, team, isLiked)); + } + + private TeamLikeToggleResponse handleFirstTimeLike(Long memberId, Team team, Boolean isLiked) { + if (!isLiked) { + throw new TeamLikeException(NOT_LIKED_YET); + } + + saveTeamLike(memberId, team, true); + 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(Long memberId, Team team, Boolean isLiked) { + try { + teamLikeRepository.save(TeamLike.builder() + .memberId(memberId) + .team(team) + .isLiked(isLiked) + .build()); + teamLikeRepository.flush(); + } catch (DataIntegrityViolationException e) { + throw new TeamLikeException(DUPLICATE_LIKE_REQUEST); + } + } +} 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/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/RestDocsTest.java b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java index 540299f..89a9002 100644 --- a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java @@ -22,6 +22,7 @@ import com.opus.opus.modules.notice.application.NoticeQueryService; import com.opus.opus.modules.team.api.TeamController; import com.opus.opus.modules.team.application.TeamCommandService; +import com.opus.opus.modules.team.application.TeamLikeCommandService; import com.opus.opus.modules.team.application.TeamQueryService; import com.opus.opus.modules.team.api.TeamMemberController; import com.opus.opus.modules.team.application.TeamMemberCommandService; @@ -85,6 +86,9 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected ContestQueryService contestQueryService; + @MockitoBean + protected TeamLikeCommandService teamLikeCommandService; + // Setting @Autowired protected WebApplicationContext context; 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 f4382dc..9941f18 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.NOT_FOUND_CONTEST; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @@ -144,7 +144,7 @@ public class ContestApiDocsTest extends RestDocsTest { 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..0b952fa --- /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(teamLikeCommandService.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(teamLikeCommandService.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(teamLikeCommandService) + .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(teamLikeCommandService) + .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(teamLikeCommandService) + .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(teamLikeCommandService) + .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(teamLikeCommandService) + .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/TeamFixture.java b/src/test/java/com/opus/opus/team/TeamFixture.java index 3a5c34d..0d46d8a 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 createTeamWithContestId(DEFAULT_CONTEST_ID); + } + + public static Team createTeamWithContestId(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/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/TeamLikeCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamLikeCommandServiceTest.java new file mode 100644 index 0000000..6315d84 --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamLikeCommandServiceTest.java @@ -0,0 +1,149 @@ +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.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 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.TeamLikeCommandService; +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.domain.dao.TeamRepository; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.modules.team.exception.TeamLikeException; +import com.opus.opus.team.TeamFixture; +import com.opus.opus.team.TeamLikeFixture; +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 TeamLikeCommandServiceTest extends IntegrationTest { + + @Autowired + private TeamLikeCommandService teamLikeCommandService; + + @Autowired + private TeamRepository teamRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private TeamLikeRepository teamLikeRepository; + @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(10), LocalDateTime.now().minusDays(5)); + contest = contestRepository.save(newContest); + + team = teamRepository.save(TeamFixture.createTeamWithContestId(contest.getId())); + member = memberRepository.save(MemberFixture.createMember()); + } + + @Test + @DisplayName("[성공] 처음 좋아요하면 TeamLike가 생성되고 좋아요가 등록된다.") + void 처음_좋아요하면_TeamLike가_생성되고_좋아요가_등록된다() { + TeamLikeToggleResponse response = teamLikeCommandService.toggleLike(member.getId(), team.getId(), true); + + assertThat(response.teamId()).isEqualTo(team.getId()); + assertThat(response.isLiked()).isTrue(); + assertThat(response.message()).isEqualTo("좋아요가 등록되었습니다."); + + TeamLike savedLike = teamLikeRepository.findByMemberIdAndTeam(member.getId(), team).orElseThrow(); + assertThat(savedLike.getIsLiked()).isTrue(); + } + + @Test + @DisplayName("[성공] 기존 좋아요를 취소할 수 있다.") + void 기존_좋아요를_취소할_수_있다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(team, member.getId(), true)); + + TeamLikeToggleResponse response = teamLikeCommandService.toggleLike(member.getId(), team.getId(), false); + + assertThat(response.isLiked()).isFalse(); + assertThat(response.message()).isEqualTo("좋아요가 취소되었습니다."); + } + + @Test + @DisplayName("[성공] 취소한 좋아요를 다시 등록할 수 있다.") + void 취소한_좋아요를_다시_등록할_수_있다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(team, member.getId(), false)); + + TeamLikeToggleResponse response = teamLikeCommandService.toggleLike(member.getId(), team.getId(), true); + + assertThat(response.isLiked()).isTrue(); + assertThat(response.message()).isEqualTo("좋아요가 등록되었습니다."); + } + + @Test + @DisplayName("[실패] 좋아요한 적 없는 팀에 취소 요청하면 예외가 발생한다.") + void 좋아요한_적_없는_팀에_취소_요청하면_예외가_발생한다() { + assertThatThrownBy(() -> teamLikeCommandService.toggleLike(member.getId(), team.getId(), false)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(NOT_LIKED_YET.errorMessage()); + } + + @Test + @DisplayName("[실패] 이미 좋아요한 팀에 다시 좋아요하면 예외가 발생한다.") + void 이미_좋아요한_팀에_다시_좋아요하면_예외가_발생한다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(team, member.getId(), true)); + + assertThatThrownBy(() -> teamLikeCommandService.toggleLike(member.getId(), team.getId(), true)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(ALREADY_LIKED.errorMessage()); + } + + @Test + @DisplayName("[실패] 이미 좋아요 취소한 팀에 다시 취소하면 예외가 발생한다.") + void 이미_좋아요_취소한_팀에_다시_취소하면_예외가_발생한다() { + teamLikeRepository.save(TeamLikeFixture.createTeamLike(team, member.getId(), false)); + + assertThatThrownBy(() -> teamLikeCommandService.toggleLike(member.getId(), team.getId(), false)) + .isInstanceOf(TeamLikeException.class) + .hasMessage(ALREADY_UNLIKED.errorMessage()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에는 좋아요할 수 없다.") + void 존재하지_않는_팀에는_좋아요할_수_없다() { + final Long invalidTeamId = 999L; + + assertThatThrownBy(() -> teamLikeCommandService.toggleLike(member.getId(), invalidTeamId, true)) + .isInstanceOf(TeamException.class) + .hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[실패] 투표 기간에는 좋아요할 수 없다.") + void 투표_기간에는_좋아요할_수_없다() { + Contest votingContest = ContestFixture.createContest(); + votingContest.updateVotePeriod(LocalDateTime.now().minusDays(1), LocalDateTime.now().plusDays(1)); + votingContest = contestRepository.save(votingContest); + + Team votingTeam = teamRepository.save(TeamFixture.createTeamWithContestId(votingContest.getId())); + + assertThatThrownBy(() -> teamLikeCommandService.toggleLike(member.getId(), votingTeam.getId(), true)) + .isInstanceOf(ContestException.class) + .hasMessage(NOT_ALLOWED_DURING_VOTING_PERIOD.errorMessage()); + } +}