diff --git a/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc new file mode 100644 index 0000000..0ead0db --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc @@ -0,0 +1,89 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += CONTEST API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:../opus.html[API 목록으로 돌아가기] + +== `GET`: 대회의 팀 목록 조회 (비회원) + +NOTE: 비회원도 접근 가능한 메인 페이지용 API입니다. 현재 시간에 따라 응답의 필드에 `isVoted` 또는 `isLiked` 필드 중 하나만 포함됩니다. + +.HTTP Request +include::{snippets}/get-contest-team-summaries/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-team-summaries/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-contest-team-summaries/path-parameters.adoc[] + +.Response Body's Fields +include::{snippets}/get-contest-team-summaries/response-fields.adoc[] + +== `GET`: 대회의 팀 목록 조회 (회원 - 미투표 기간) + +NOTE: 회원이 조회할 수 있는 API입니다. 미투표 기간에는 `isLiked` 필드가 포함됩니다. + +.HTTP Request Headers +include::{snippets}/get-contest-team-summaries-with-auth/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-contest-team-summaries-with-auth/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-team-summaries-with-auth/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-contest-team-summaries-with-auth/path-parameters.adoc[] + +.Response Body's Fields +include::{snippets}/get-contest-team-summaries-with-auth/response-fields.adoc[] + +== `GET`: 대회의 팀 목록 조회 (회원 - 투표 기간) + +NOTE: 회원이 조회할 수 있는 API입니다. 투표 기간에는 `isVoted` 필드가 포함됩니다. + +.HTTP Request Headers +include::{snippets}/get-contest-team-summaries-with-auth-voting/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-contest-team-summaries-with-auth-voting/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-team-summaries-with-auth-voting/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-contest-team-summaries-with-auth-voting/path-parameters.adoc[] + +.Response Body's Fields +include::{snippets}/get-contest-team-summaries-with-auth-voting/response-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 ID + +[%collapsible] + +==== + +.HTTP Request +include::{snippets}/get-contest-team-summaries-fail-contest-not-found/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-team-summaries-fail-contest-not-found/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-contest-team-summaries-fail-contest-not-found/path-parameters.adoc[] + +==== + 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 2a1b6c4..57c727d 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/opus.adoc @@ -11,3 +11,9 @@ endif::[] == 멤버 관련 API link:./member.html[회원 API] + +== 공지사항 관련 API +link:./notice.html[공지사항 API] + +== 대회 관련 API +link:./contest.html[대회 API] 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 3c0ebee..5f3f1e2 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; @@ -7,6 +8,8 @@ import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse; 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.TeamSummaryResponse; +import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.team.application.dto.ImageResponse; import jakarta.validation.Valid; import java.util.List; @@ -101,4 +104,13 @@ public ResponseEntity> getCurrentContests() { List responses = contestQueryService.getCurrentContests(); return ResponseEntity.ok(responses); } + + @GetMapping("/{contestId}/teams") + public ResponseEntity> getAllContestTeamSummaries( + @PathVariable final Long contestId, + @LoginMember final Member member + ) { + final List responses = contestQueryService.getContestTeamSummaries(contestId, member); + return ResponseEntity.ok(responses); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java index 941f0f8..b144ed7 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java @@ -6,18 +6,29 @@ import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_WEBP_CONVERTED; import com.opus.opus.global.util.FileStorageUtil; +import com.opus.opus.modules.contest.application.convenience.ContestAwardConvenience; import com.opus.opus.modules.contest.application.convenience.ContestCategoryConvenience; import com.opus.opus.modules.contest.application.convenience.ContestConvenience; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse; import com.opus.opus.modules.contest.application.dto.response.ContestResponse; +import com.opus.opus.modules.contest.application.dto.response.TeamSummaryResponse; import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestAward; import com.opus.opus.modules.contest.domain.ContestCategory; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.file.application.convenience.FileConvenience; import com.opus.opus.modules.file.domain.File; import com.opus.opus.modules.file.exception.FileException; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.convenience.TeamConvenience; +import com.opus.opus.modules.team.application.convenience.TeamLikeConvenience; +import com.opus.opus.modules.team.application.convenience.TeamVoteConvenience; import com.opus.opus.modules.team.application.dto.ImageResponse; +import com.opus.opus.modules.team.domain.Team; +import java.time.LocalDateTime; +import java.util.Collections; import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.antlr.v4.runtime.misc.Pair; import org.springframework.core.io.Resource; @@ -35,7 +46,11 @@ public class ContestQueryService { private final ContestCategoryConvenience contestCategoryConvenience; private final ContestConvenience contestConvenience; + private final ContestAwardConvenience contestAwardConvenience; private final FileConvenience fileConvenience; + private final TeamConvenience teamConvenience; + private final TeamLikeConvenience teamLikeConvenience; + private final TeamVoteConvenience teamVoteConvenience; public ImageResponse getContestBanner(final Long contestId) { contestConvenience.getValidateExistContest(contestId); @@ -72,6 +87,50 @@ public List getAllContests() { .toList(); } + public List getContestTeamSummaries(final Long contestId, final Member member) { + final Contest contest = contestConvenience.getValidateExistContest(contestId); + final List teams = teamConvenience.findAllByContestId(contestId); + + final boolean isVotingPeriod = checkVotingPeriod(contest); + + final Pair, Map> voteAndLikeMaps = getVoteAndLikeMaps(teams, member, + isVotingPeriod); + final Map voteMap = voteAndLikeMaps.a; + final Map likeMap = voteAndLikeMaps.b; + + final List teamAwards = contestAwardConvenience.getTeamAwards(teams); + + teamConvenience.shuffleTeams(teams, member); + + return teams.stream() + .map(team -> TeamSummaryResponse.of(team, teamAwards, + likeMap.getOrDefault(team.getId(), false), + voteMap.getOrDefault(team.getId(), false), + isVotingPeriod)).toList(); + + } + + private Pair, Map> getVoteAndLikeMaps( + final List teams, final Member member, final boolean isVotingPeriod) { + if (isVotingPeriod) { + return new Pair<>( + teamVoteConvenience.getVoteMap(teams, member), + Collections.emptyMap() + ); + } else { + return new Pair<>( + Collections.emptyMap(), + teamLikeConvenience.getLikeMap(teams, member) + ); + } + } + + private boolean checkVotingPeriod(final Contest contest) { + final LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(contest.getVoteStartAt()) + && !now.isAfter(contest.getVoteEndAt()); + } + private void checkImageConverted(final File findFile) { if (!findFile.getIsWebpConverted()) { throw new FileException(NOT_WEBP_CONVERTED); diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestAwardConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestAwardConvenience.java index f2cf290..057ff45 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestAwardConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestAwardConvenience.java @@ -5,6 +5,10 @@ import com.opus.opus.modules.contest.domain.ContestAward; import com.opus.opus.modules.contest.domain.dao.ContestAwardRepository; import com.opus.opus.modules.contest.exception.ContestAwardException; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamContestAward; +import com.opus.opus.modules.team.domain.dao.TeamContestAwardRepository; +import java.util.Collections; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,6 +18,7 @@ public class ContestAwardConvenience { private final ContestAwardRepository contestAwardRepository; + private final TeamContestAwardRepository teamContestAwardRepository; public List findAllById(final List awardIds) { final List contestAwards = contestAwardRepository.findAllById(awardIds); @@ -24,4 +29,18 @@ public List findAllById(final List awardIds) { return contestAwards; } + + public List getTeamAwards(final List teams) { + final List teamIds = teams.stream().map(Team::getId).toList(); + + final List teamAwards = teamContestAwardRepository.findByTeamIdIn(teamIds); + + if (teamAwards.isEmpty()) { + return Collections.emptyList(); + } + + final List awardIds = teamAwards.stream().map(TeamContestAward::getContestAwardId).distinct().toList(); + + return findAllById(awardIds); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamSummaryResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamSummaryResponse.java new file mode 100644 index 0000000..a976e2b --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamSummaryResponse.java @@ -0,0 +1,58 @@ +package com.opus.opus.modules.contest.application.dto.response; + +import com.opus.opus.modules.contest.domain.ContestAward; +import com.opus.opus.modules.team.domain.Team; +import java.util.List; + +public record TeamSummaryResponse( + Long teamId, + String teamName, + String projectName, + Boolean isLiked, + Boolean isVoted, + List awards +) { + public static TeamSummaryResponse of( + final Team team, + final List contestAwards, + final Boolean isLiked, + final Boolean isVoted, + final boolean isVotingPeriod + ) { + final List awardInfos = contestAwards.stream() + .map(AwardInfo::from) + .toList(); + + if (isVotingPeriod) { + return new TeamSummaryResponse( + team.getId(), + team.getTeamName(), + team.getProjectName(), + null, + isVoted, + awardInfos + ); + } else { + return new TeamSummaryResponse( + team.getId(), + team.getTeamName(), + team.getProjectName(), + isLiked, + null, + awardInfos + ); + } + } + + public record AwardInfo( + String awardName, + String awardColor + ) { + public static AwardInfo from(final ContestAward contestAward) { + return new AwardInfo( + contestAward.getAwardName(), + contestAward.getAwardColor() + ); + } + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamContestAwardConvenience.java b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamContestAwardConvenience.java new file mode 100644 index 0000000..e5ecc6d --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamContestAwardConvenience.java @@ -0,0 +1,36 @@ +package com.opus.opus.modules.team.application.convenience; + +import com.opus.opus.modules.contest.application.convenience.ContestAwardConvenience; +import com.opus.opus.modules.contest.domain.ContestAward; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamContestAward; +import com.opus.opus.modules.team.domain.dao.TeamContestAwardRepository; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamContestAwardConvenience { + + private final TeamContestAwardRepository teamContestAwardRepository; + + private final ContestAwardConvenience contestAwardConvenience; + + public List getTeamAwards(final List teams) { + final List teamIds = teams.stream().map(Team::getId).toList(); + + final List teamAwards = teamContestAwardRepository.findByTeamIdIn(teamIds); + + if (teamAwards.isEmpty()) { + return Collections.emptyList(); + } + + final List awardIds = teamAwards.stream().map(TeamContestAward::getContestAwardId).distinct().toList(); + + return contestAwardConvenience.findAllById(awardIds); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamConvenience.java b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamConvenience.java index 41c7583..0dfb85d 100644 --- a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamConvenience.java +++ b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamConvenience.java @@ -5,9 +5,13 @@ import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; import static com.opus.opus.modules.team.exception.TeamExceptionType.TRACK_HAS_TEAM; +import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.dao.TeamRepository; import com.opus.opus.modules.team.exception.TeamException; +import java.util.Collections; +import java.util.List; +import java.util.Random; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,4 +43,17 @@ public void validateAllTeamsDeletedInTrack(final Long trackId) { } } + public List findAllByContestId(final Long contestId) { + return teamRepository.findByContestId(contestId); + } + + public void shuffleTeams(final List teams, final Member member) { + if (member != null) { + Random seed = new Random(member.getId()); + Collections.shuffle(teams, seed); + } else { + Collections.shuffle(teams); + } + } + } diff --git a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamLikeConvenience.java b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamLikeConvenience.java new file mode 100644 index 0000000..0041898 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamLikeConvenience.java @@ -0,0 +1,28 @@ +package com.opus.opus.modules.team.application.convenience; + +import static java.util.stream.Collectors.toMap; + +import com.opus.opus.modules.member.domain.Member; +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 java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamLikeConvenience { + + private final TeamLikeRepository teamLikeRepository; + + public Map getLikeMap(final List teams, final Member member) { + return (member != null) ? teamLikeRepository.findAllByMemberIdAndTeamIn(member.getId(), teams).stream() + .collect(toMap(tl -> tl.getTeam().getId(), TeamLike::getIsLiked)) + : Collections.emptyMap(); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamVoteConvenience.java b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamVoteConvenience.java new file mode 100644 index 0000000..6dc5f88 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamVoteConvenience.java @@ -0,0 +1,28 @@ +package com.opus.opus.modules.team.application.convenience; + +import static java.util.stream.Collectors.toMap; + +import com.opus.opus.modules.member.domain.Member; +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 java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamVoteConvenience { + + private final TeamVoteRepository teamVoteRepository; + + public Map getVoteMap(final List teams, final Member member) { + return (member != null) ? teamVoteRepository.findAllByMemberIdAndTeamIn(member.getId(), teams).stream() + .collect(toMap(tv -> tv.getTeam().getId(), TeamVote::getIsVoted)) + : Collections.emptyMap(); + } +} 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 0000000..3c13057 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamVote.java @@ -0,0 +1,43 @@ +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 + public TeamVote(final Long memberId, final Team team, final Boolean isVoted) { + this.memberId = memberId; + this.team = team; + this.isVoted = isVoted; + } +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamContestAwardRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamContestAwardRepository.java index 4f76baa..6446501 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamContestAwardRepository.java +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamContestAwardRepository.java @@ -9,4 +9,6 @@ public interface TeamContestAwardRepository extends JpaRepository { List findByTeamId(final Long teamId); + + List findByTeamIdIn(final List teamIds); } 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..c493369 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamLikeRepository.java @@ -0,0 +1,12 @@ +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.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamLikeRepository extends JpaRepository { + + List findAllByMemberIdAndTeamIn(final Long memberId, final List teams); + +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamRepository.java index efa5dd0..31a458e 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamRepository.java +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamRepository.java @@ -1,6 +1,7 @@ package com.opus.opus.modules.team.domain.dao; import com.opus.opus.modules.team.domain.Team; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,4 +11,5 @@ public interface TeamRepository extends JpaRepository { boolean existsByTrackId(final Long trackId); + List findByContestId(Long contestId); } 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 0000000..be816d5 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamVoteRepository.java @@ -0,0 +1,10 @@ +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.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamVoteRepository extends JpaRepository { + List findAllByMemberIdAndTeamIn(final Long memberId, final List teams); +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6d06ffb..56f919e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -13,6 +13,7 @@ DROP TABLE IF EXISTS `team_comment`; DROP TABLE IF EXISTS `team_contest_award`; DROP TABLE IF EXISTS `team_like`; DROP TABLE IF EXISTS `team_member`; +DROP TABLE IF EXISTS `team_vote`; DROP TABLE IF EXISTS `team_member_roles`; DROP TABLE IF EXISTS `team_sort`; @@ -154,6 +155,16 @@ CREATE TABLE `team_like` ( 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`) +); + CREATE TABLE `team_member` ( `id` bigint NOT NULL AUTO_INCREMENT, `created_at` datetime(6) DEFAULT NULL, diff --git a/src/test/java/com/opus/opus/contest/ContestCategoryFixture.java b/src/test/java/com/opus/opus/contest/ContestCategoryFixture.java new file mode 100644 index 0000000..aed7a46 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestCategoryFixture.java @@ -0,0 +1,13 @@ +package com.opus.opus.contest; + +import com.opus.opus.modules.contest.domain.ContestCategory; + +public class ContestCategoryFixture { + + public static ContestCategory createContestCategory() { + return ContestCategory.builder() + .categoryName("테스트 카테고리") + .build(); + } +} + diff --git a/src/test/java/com/opus/opus/contest/ContestFixture.java b/src/test/java/com/opus/opus/contest/ContestFixture.java new file mode 100644 index 0000000..37a85be --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestFixture.java @@ -0,0 +1,14 @@ +package com.opus.opus.contest; + +import com.opus.opus.modules.contest.domain.Contest; + +public class ContestFixture { + + public static Contest createContest(final Long categoryId) { + return Contest.builder() + .contestName("테스트 대회") + .categoryId(categoryId) + .build(); + } +} + diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java new file mode 100644 index 0000000..62553f2 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -0,0 +1,68 @@ +package com.opus.opus.contest.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.opus.opus.contest.ContestCategoryFixture; +import com.opus.opus.contest.ContestFixture; +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.contest.application.ContestQueryService; +import com.opus.opus.modules.contest.application.dto.response.TeamSummaryResponse; +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestCategory; +import com.opus.opus.modules.contest.domain.dao.ContestCategoryRepository; +import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.dao.TeamRepository; +import com.opus.opus.team.TeamFixture; +import java.util.List; +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 ContestQueryServiceTest extends IntegrationTest { + + @Autowired + private ContestQueryService contestQueryService; + + @Autowired + private ContestRepository contestRepository; + + @Autowired + private ContestCategoryRepository contestCategoryRepository; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private MemberRepository memberRepository; + + private ContestCategory category; + private Contest contest; + private Member member; + + @BeforeEach + void setUp() { + category = contestCategoryRepository.save(ContestCategoryFixture.createContestCategory()); + contest = contestRepository.save(ContestFixture.createContest(category.getId())); + member = memberRepository.save(MemberFixture.createMember()); + } + + @Test + @DisplayName("[성공] 대회의 팀 목록을 조회할 수 있다.") + void 대회의_팀_목록을_조회할_수_있다() { + final Team team1 = teamRepository.save(TeamFixture.createTeam(contest.getId())); + final Team team2 = teamRepository.save(TeamFixture.createTeam(contest.getId())); + + final List responses = contestQueryService.getContestTeamSummaries(contest.getId(), + member); + + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(TeamSummaryResponse::teamId) + .containsExactlyInAnyOrder(team1.getId(), team2.getId()); + } +} diff --git a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java index 9452db4..f45a0e0 100644 --- a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java @@ -6,6 +6,9 @@ import com.opus.opus.global.security.JwtProvider; import com.opus.opus.helper.ApiTestHelper; +import com.opus.opus.modules.contest.api.ContestController; +import com.opus.opus.modules.contest.application.ContestCommandService; +import com.opus.opus.modules.contest.application.ContestQueryService; import com.opus.opus.modules.member.api.MemberController; import com.opus.opus.modules.member.application.MemberCommandService; import com.opus.opus.modules.member.application.MemberQueryService; @@ -29,7 +32,8 @@ @WebMvcTest({ MemberController.class, - NoticeController.class + NoticeController.class, + ContestController.class }) @Import(RestDocsConfig.class) @ExtendWith(RestDocumentationExtension.class) @@ -48,6 +52,12 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected NoticeQueryService noticeQueryService; + @MockitoBean + protected ContestCommandService contestCommandService; + + @MockitoBean + protected ContestQueryService contestQueryService; + // 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 new file mode 100644 index 0000000..39d468d --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -0,0 +1,173 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.when; +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.payload.PayloadDocumentation.fieldWithPath; +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.application.dto.response.TeamSummaryResponse; +import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.restdocs.RestDocsTest; +import java.util.List; +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.restdocs.payload.JsonFieldType; + +public class ContestApiDocsTest 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 List awards1 = List.of( + new TeamSummaryResponse.AwardInfo("대상", "#FF0000"), + new TeamSummaryResponse.AwardInfo("우수상", "#00A3FF") + ); + final List awards2 = List.of(); + + final List responses = List.of( + new TeamSummaryResponse(1L, "team1", "team1 Project", false, null, awards1), + new TeamSummaryResponse(2L, "team2", "team2 Project", false, null, awards2) + ); + + when(contestQueryService.getContestTeamSummaries(anyLong(), any())).thenReturn(responses); + + mockMvc.perform(get("/contests/{contestId}/teams", 1L)) + .andExpect(status().isOk()) + .andDo(document("get-contest-team-summaries", + pathParameters( + parameterWithName("contestId").description("대회의 고유 ID") + ), + responseFields( + arrayFieldWithPath("[]", "팀 목록"), + numberFieldWithPath("[].teamId", "팀 ID"), + stringFieldWithPath("[].teamName", "팀명"), + stringFieldWithPath("[].projectName", "프로젝트명"), + booleanFieldWithPath("[].isLiked", "좋아요 여부 (미투표 기간, 비회원은 항상 false)"), + fieldWithPath("[].isVoted").optional().type(JsonFieldType.BOOLEAN).description("투표 여부 (투표 기간인 경우, 미투표 기간에는 null)"), + arrayFieldWithPath("[].awards", "수상 목록"), + stringFieldWithPath("[].awards[].awardName", "수상명"), + stringFieldWithPath("[].awards[].awardColor", "수상 색상") + ) + )); + } + + @Test + @DisplayName("[성공] 회원이 대회의 팀 목록을 조회할 수 있다. (미투표 기간)") + void 회원이_대회의_팀_목록을_조회할_수_있다_미투표_기간() throws Exception { + final List awards1 = List.of( + new TeamSummaryResponse.AwardInfo("대상", "#FF0000") + ); + final List awards2 = List.of(); + + final List responses = List.of( + new TeamSummaryResponse(1L, "team1", "team1 Project", true, null, awards1), + new TeamSummaryResponse(2L, "team2", "team2 Project", false, null, awards2) + ); + + when(contestQueryService.getContestTeamSummaries(anyLong(), any(Member.class))).thenReturn(responses); + + mockMvc.perform(get("/contests/{contestId}/teams", 1L) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("get-contest-team-summaries-with-auth", + pathParameters( + parameterWithName("contestId").description("대회의 고유 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (선택)") + ), + responseFields( + arrayFieldWithPath("[]", "팀 목록"), + numberFieldWithPath("[].teamId", "팀 ID"), + stringFieldWithPath("[].teamName", "팀명"), + stringFieldWithPath("[].projectName", "프로젝트명"), + booleanFieldWithPath("[].isLiked", "좋아요 여부 (미투표 기간, 회원은 로그인한 사용자의 좋아요 여부에 따라)"), + fieldWithPath("[].isVoted").optional().type(JsonFieldType.BOOLEAN).description("투표 여부 (투표 기간인 경우, 미투표 기간에는 null)"), + arrayFieldWithPath("[].awards", "수상 목록"), + stringFieldWithPath("[].awards[].awardName", "수상명"), + stringFieldWithPath("[].awards[].awardColor", "수상 색상") + ) + )); + } + + @Test + @DisplayName("[성공] 회원이 대회의 팀 목록을 조회할 수 있다. (투표 기간)") + void 회원이_대회의_팀_목록을_조회할_수_있다_투표_기간() throws Exception { + final List awards1 = List.of( + new TeamSummaryResponse.AwardInfo("대상", "#FF0000"), + new TeamSummaryResponse.AwardInfo("우수상", "#00A3FF") + ); + final List awards2 = List.of(); + + final List responses = List.of( + new TeamSummaryResponse(1L, "team1", "team1 Project", null, true, awards1), + new TeamSummaryResponse(2L, "team2", "team2 Project", null, false, awards2) + ); + + when(contestQueryService.getContestTeamSummaries(anyLong(), any(Member.class))).thenReturn(responses); + + mockMvc.perform(get("/contests/{contestId}/teams", 1L) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("get-contest-team-summaries-with-auth-voting", + pathParameters( + parameterWithName("contestId").description("대회의 고유 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (선택)") + ), + responseFields( + arrayFieldWithPath("[]", "팀 목록"), + numberFieldWithPath("[].teamId", "팀 ID"), + stringFieldWithPath("[].teamName", "팀명"), + stringFieldWithPath("[].projectName", "프로젝트명"), + fieldWithPath("[].isLiked").optional().type(JsonFieldType.BOOLEAN).description("좋아요 여부 (미투표 기간인 경우, 투표 기간에는 null)"), + booleanFieldWithPath("[].isVoted", "투표 여부 (투표 기간인 경우, 회원은 로그인한 사용자의 투표 여부에 따라)"), + arrayFieldWithPath("[].awards", "수상 목록"), + stringFieldWithPath("[].awards[].awardName", "수상명"), + stringFieldWithPath("[].awards[].awardColor", "수상 색상") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회 ID로 조회 시 404 에러를 반환한다.") + void 존재하지_않는_대회_ID로_조회_시_에러를_반환한다() throws Exception { + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(contestQueryService) + .getContestTeamSummaries(anyLong(), any()); + + mockMvc.perform(get("/contests/{contestId}/teams", 999L)) + .andExpect(status().isNotFound()) + .andDo(document("get-contest-team-summaries-fail-contest-not-found", + pathParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/team/TeamFixture.java b/src/test/java/com/opus/opus/team/TeamFixture.java new file mode 100644 index 0000000..3808c4b --- /dev/null +++ b/src/test/java/com/opus/opus/team/TeamFixture.java @@ -0,0 +1,25 @@ +package com.opus.opus.team; + +import com.opus.opus.modules.team.domain.Team; + +public class TeamFixture { + + public static Team createTeam() { + return Team.builder() + .teamName("테스트 팀") + .projectName("테스트 프로젝트") + .contestId(1L) + .itemOrder(1) + .build(); + } + + public static Team createTeam(final Long contestId) { + return Team.builder() + .teamName("테스트 팀") + .projectName("테스트 프로젝트") + .contestId(contestId) + .itemOrder(1) + .build(); + } +} +