diff --git a/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc index 131d446b..08dddd71 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc @@ -193,3 +193,100 @@ include::{snippets}/delete-contest-banner/http-request.adoc[] .HTTP Response include::{snippets}/delete-contest-banner/http-response.adoc[] + +== 정렬 관리 + +=== `PUT`: 대회 정렬 설정 변경 + +.Path Parameters +include::{snippets}/update-contest-sort/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/update-contest-sort/request-headers.adoc[] + +.HTTP Request +include::{snippets}/update-contest-sort/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-contest-sort/http-response.adoc[] + +.Request Fields +include::{snippets}/update-contest-sort/request-fields.adoc[] + +=== `GET`: 대회 정렬 방식 조회 + +.Path Parameters +include::{snippets}/get-contest-sort/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/get-contest-sort/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-contest-sort/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-sort/http-response.adoc[] + +.Response Fields +include::{snippets}/get-contest-sort/response-fields.adoc[] + +=== `PUT`: 대회 수동 정렬 순서 저장 + +.Path Parameters +include::{snippets}/update-contest-sort-custom/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/update-contest-sort-custom/request-headers.adoc[] + +.HTTP Request +include::{snippets}/update-contest-sort-custom/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-contest-sort-custom/http-response.adoc[] + +.Request Fields +include::{snippets}/update-contest-sort-custom/request-fields.adoc[] + +==== ⚠️ 실패 케이스 + +.❌ Case 1: CUSTOM 모드가 아님 + +[%collapsible] +==== +include::{snippets}/update-contest-sort-custom-fail-mode/http-request.adoc[] +include::{snippets}/update-contest-sort-custom-fail-mode/http-response.adoc[] +==== + +.❌ Case 2: request에 중복된 teamId 존재 + +[%collapsible] +==== +include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/http-request.adoc[] +include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/http-response.adoc[] +include::{snippets}/update-contest-sort-custom-fail-duplicate-teamId/request-fields.adoc[] +==== + +.❌ Case 3: request에 중복된 itemOrder 존재 + +[%collapsible] +==== +include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/http-request.adoc[] +include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/http-response.adoc[] +include::{snippets}/update-contest-sort-custom-fail-duplicate-itemOrder/request-fields.adoc[] +==== + +.❌ Case 4: 요청 팀 개수와 저장된 팀 개수 다름 + +[%collapsible] +==== +include::{snippets}/update-contest-sort-custom-fail-different-size/http-request.adoc[] +include::{snippets}/update-contest-sort-custom-fail-different-size/http-response.adoc[] +==== + +.❌ Case 5: itemOrder가 팀 개수를 넘어감 + +[%collapsible] +==== +include::{snippets}/update-contest-sort-custom-fail-over-itemOrder/http-request.adoc[] +include::{snippets}/update-contest-sort-custom-fail-over-itemOrder/http-response.adoc[] +==== diff --git a/src/main/java/com/opus/opus/modules/contest/api/ContestController.java b/src/main/java/com/opus/opus/modules/contest/api/ContestController.java index 90a66bec..73c9c239 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 @@ -4,11 +4,14 @@ import com.opus.opus.modules.contest.application.ContestQueryService; import com.opus.opus.modules.contest.application.dto.request.ContestCurrentToggleRequest; import com.opus.opus.modules.contest.application.dto.request.ContestRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest; import com.opus.opus.modules.contest.application.dto.request.ContestVotesLimitRequest; import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; 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.ContestSortResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.team.application.dto.ImageResponse; @@ -134,4 +137,26 @@ public ResponseEntity getMaxVotesLimit(@PathVariable final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contestId); return ResponseEntity.ok(response); } + + @Secured("ROLE_관리자") + @PutMapping("/{contestId}/sort") + public ResponseEntity updateContestSort(@PathVariable final Long contestId, + @RequestBody @Valid final ContestSortRequest request) { + contestCommandService.updateContestSort(contestId, request); + return ResponseEntity.noContent().build(); + } + + @Secured("ROLE_관리자") + @GetMapping("/{contestId}/sort") + public ResponseEntity getContestSort(@PathVariable final Long contestId) { + return ResponseEntity.ok(contestQueryService.getContestSort(contestId)); + } + + @Secured("ROLE_관리자") + @PutMapping("/{contestId}/sort/custom") + public ResponseEntity updateContestSortCustom(@PathVariable final Long contestId, + @RequestBody final List<@Valid ContestSortCustomRequest> requests) { + contestCommandService.updateContestSortCustom(contestId, requests); + return ResponseEntity.noContent().build(); + } } 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 d67655d3..ffb4c43a 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 @@ -1,32 +1,48 @@ package com.opus.opus.modules.contest.application; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.*; +import static com.opus.opus.modules.contest.domain.SortType.CUSTOM; 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.contest.exception.ContestExceptionType.DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_TEAM_ID_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.INVALID_CONTEST_SORT_CUSTOM_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_EXIST_TEAM_IN_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.ONLY_CUSTOM_MODE_CAN_CHANGE; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.VOTE_END_PRECEDE_VOTE_START; import static com.opus.opus.modules.file.domain.FileImageType.BANNER; import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST; import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_WEBP_CONVERTED; +import static com.opus.opus.modules.team.exception.TeamExceptionType.INVALID_ITEM_ORDER; +import static java.util.function.Function.identity; +import static java.util.stream.Collectors.toMap; import com.opus.opus.global.util.FileStorageUtil; 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.convenience.ContestSortConvenience; import com.opus.opus.modules.contest.application.dto.request.ContestRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest; 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.ContestSort; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestSortRepository; 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; import com.opus.opus.modules.team.application.convenience.TeamConvenience; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.exception.TeamException; +import java.util.List; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -40,10 +56,12 @@ public class ContestCommandService { private static final int MAX_CURRENT_CONTEST_COUNT = 2; private final ContestRepository contestRepository; + private final ContestSortRepository contestSortRepository; private final FileRepository fileRepository; private final ContestConvenience contestConvenience; private final ContestCategoryConvenience contestCategoryConvenience; + private final ContestSortConvenience contestSortConvenience; private final TeamConvenience teamConvenience; private final FileStorageUtil fileStorageUtil; @@ -76,6 +94,10 @@ public ContestResponse createContest(final ContestRequest request) { .build(); contestRepository.save(contest); + contestSortRepository.save(ContestSort.builder() + .contest(contest) + .build()); + return ContestResponse.from(contest, contestCategory.getCategoryName()); } @@ -127,6 +149,28 @@ public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimi contest.updateMaxVotesLimit(maxVotesLimit); } + public void updateContestSort(final Long contestId, final ContestSortRequest request) { + contestConvenience.validateExistContest(contestId); + final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contestId); + + contestSort.updateMode(request.mode()); + } + + //todo: 팀 생성 시 itemOrder 추가 + public void updateContestSortCustom(final Long contestId, final List requests) { + final Contest contest = contestConvenience.getValidateExistContestForUpdate(contestId); + final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contest.getId()); + checkCustomSort(contestSort); + validateDuplicateTeamIds(requests); + + final List teams = teamConvenience.getTeamsOfContest(contestId); + validateRequestSizeMatchesTeams(requests, teams); + validateItemOrderRange(requests, teams.size()); + validateDuplicateItemOrders(requests); + + applyCustomSortToTeams(requests, teams); + } + private void validateNotInVotingPeriod(final Contest contest) { if (contest.isVotingPeriod()) { throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD); @@ -140,7 +184,7 @@ private void checkWebpConverted(File existingFile) { } private void validateSameCurrentRequest(final Boolean currentValue, final Boolean requestValue) { - if (currentValue == requestValue) { + if (currentValue.equals(requestValue)) { throw new ContestException(currentValue ? ALREADY_CURRENT_CONTEST : ALREADY_NOT_CURRENT_CONTEST); } } @@ -150,4 +194,51 @@ private void validateCurrentContestLimit(final long currentCount) { throw new ContestException(CURRENT_CONTEST_LIMIT_EXCEEDED); } } + + private void validateItemOrderRange(final List requests, final int teamCount) { + for (ContestSortCustomRequest r : requests) { + final int order = r.itemOrder(); + if (order < 1 || order > teamCount) { + throw new TeamException(INVALID_ITEM_ORDER); + } + } + } + + private void checkCustomSort(final ContestSort contestSort) { + if (contestSort.getMode() != CUSTOM) { + throw new ContestException(ONLY_CUSTOM_MODE_CAN_CHANGE); + } + } + + private void validateRequestSizeMatchesTeams(final List requests, + final List teams) { + if (requests.size() != teams.size()) { + throw new ContestException(INVALID_CONTEST_SORT_CUSTOM_REQUEST); + } + } + + private void validateDuplicateTeamIds(final List requests) { + if (requests.stream().map(ContestSortCustomRequest::teamId).distinct().count() != requests.size()) { + throw new ContestException(DUPLICATE_TEAM_ID_IN_SORT_REQUEST); + } + } + + private void validateDuplicateItemOrders(final List requests) { + if (requests.stream().map(ContestSortCustomRequest::itemOrder).distinct().count() != requests.size()) { + throw new ContestException(DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST); + } + } + + private void applyCustomSortToTeams(final List requests, final List teams) { + final Map teamMap = teams.stream() + .collect(toMap(Team::getId, identity())); + + for (ContestSortCustomRequest r : requests) { + final Team team = teamMap.get(r.teamId()); + if (team == null) { + throw new ContestException(NOT_EXIST_TEAM_IN_CONTEST); + } + team.updateItemOrder(r.itemOrder()); + } + } } 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 2e9aee64..8225a27d 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 @@ -8,12 +8,15 @@ import com.opus.opus.global.util.FileStorageUtil; 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.convenience.ContestSortConvenience; 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.ContestSortResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.ContestCategory; +import com.opus.opus.modules.contest.domain.ContestSort; 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; @@ -37,6 +40,7 @@ public class ContestQueryService { private final ContestCategoryConvenience contestCategoryConvenience; private final ContestConvenience contestConvenience; + private final ContestSortConvenience contestSortConvenience; private final FileConvenience fileConvenience; public ImageResponse getContestBanner(final Long contestId) { @@ -84,6 +88,13 @@ public ContestVotesLimitResponse getMaxVotesLimit(final Long contestId) { return ContestVotesLimitResponse.from(contest.getMaxVotesLimit()); } + public ContestSortResponse getContestSort(final Long contestId) { + contestConvenience.validateExistContest(contestId); + final ContestSort contestSort = contestSortConvenience.getValidateExistContestSort(contestId); + + return new ContestSortResponse(contestSort.getMode()); + } + 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/ContestConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java index 627bbe01..f5703c2e 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java @@ -4,6 +4,7 @@ import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static org.springframework.transaction.annotation.Propagation.MANDATORY; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.dao.ContestRepository; @@ -47,4 +48,10 @@ public long countCurrentContests() { public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } + + @Transactional(propagation = MANDATORY) + public Contest getValidateExistContestForUpdate(final Long contestId) { + return contestRepository.findByIdForUpdate(contestId) + .orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST)); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestSortConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestSortConvenience.java new file mode 100644 index 00000000..1d814f1d --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestSortConvenience.java @@ -0,0 +1,23 @@ +package com.opus.opus.modules.contest.application.convenience; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST_SORT; + +import com.opus.opus.modules.contest.domain.ContestSort; +import com.opus.opus.modules.contest.domain.dao.ContestSortRepository; +import com.opus.opus.modules.contest.exception.ContestException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContestSortConvenience { + + private final ContestSortRepository contestSortRepository; + + public ContestSort getValidateExistContestSort(final Long contestId) { + return contestSortRepository.findByContestId(contestId) + .orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST_SORT)); + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortCustomRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortCustomRequest.java new file mode 100644 index 00000000..ac76fc2c --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortCustomRequest.java @@ -0,0 +1,13 @@ +package com.opus.opus.modules.contest.application.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record ContestSortCustomRequest( + + @NotNull(message = "팀 ID를 입력해주세요.") + Long teamId, + + @NotNull(message = "변경된 아이템 순서를 입력해주세요.") + Integer itemOrder +) { +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortRequest.java new file mode 100644 index 00000000..63fae2ea --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestSortRequest.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.contest.application.dto.request; + +import com.opus.opus.modules.contest.domain.SortType; +import jakarta.validation.constraints.NotNull; + +public record ContestSortRequest( + + @NotNull(message = "모드를 입력하세요.") + SortType mode +) { +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestSortResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestSortResponse.java new file mode 100644 index 00000000..9926568e --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestSortResponse.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.contest.application.dto.response; + +import com.opus.opus.modules.contest.domain.SortType; + +public record ContestSortResponse( + + SortType currentMode +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamSort.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestSort.java similarity index 67% rename from src/main/java/com/opus/opus/modules/team/domain/TeamSort.java rename to src/main/java/com/opus/opus/modules/contest/domain/ContestSort.java index 3ad19952..6516ee4a 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/TeamSort.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestSort.java @@ -1,6 +1,6 @@ -package com.opus.opus.modules.team.domain; +package com.opus.opus.modules.contest.domain; -import static com.opus.opus.modules.team.domain.SortType.RANDOM; +import static com.opus.opus.modules.contest.domain.SortType.RANDOM; import static jakarta.persistence.FetchType.LAZY; import com.opus.opus.global.base.BaseEntity; @@ -21,7 +21,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class TeamSort extends BaseEntity { +public class ContestSort extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -32,12 +32,16 @@ public class TeamSort extends BaseEntity { private SortType mode; @OneToOne(fetch = LAZY) - @JoinColumn(name = "team_id", nullable = false) - private Team team; + @JoinColumn(name = "contest_id", nullable = false, unique = true) + private Contest contest; @Builder - private TeamSort(final Team team) { + private ContestSort(final Contest contest) { this.mode = RANDOM; - this.team = team; + this.contest = contest; + } + + public void updateMode(final SortType mode) { + this.mode = mode; } } diff --git a/src/main/java/com/opus/opus/modules/team/domain/SortType.java b/src/main/java/com/opus/opus/modules/contest/domain/SortType.java similarity index 55% rename from src/main/java/com/opus/opus/modules/team/domain/SortType.java rename to src/main/java/com/opus/opus/modules/contest/domain/SortType.java index 0de64545..f12fb3aa 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/SortType.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/SortType.java @@ -1,6 +1,7 @@ -package com.opus.opus.modules.team.domain; +package com.opus.opus.modules.contest.domain; public enum SortType { + ASC, RANDOM, CUSTOM diff --git a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestRepository.java b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestRepository.java index 59c2870b..c7a6ce81 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestRepository.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestRepository.java @@ -1,10 +1,16 @@ package com.opus.opus.modules.contest.domain.dao; +import static jakarta.persistence.LockModeType.PESSIMISTIC_WRITE; + import com.opus.opus.modules.contest.domain.Contest; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; public interface ContestRepository extends JpaRepository { + long countByIsCurrentTrue(); boolean existsByCategoryId(final Long categoryId); @@ -12,4 +18,8 @@ public interface ContestRepository extends JpaRepository { boolean existsByContestName(final String contestName); List findAllByIsCurrentTrue(); + + @Lock(PESSIMISTIC_WRITE) + @Query("select c from Contest c where c.id = :contestId") + Optional findByIdForUpdate(final Long contestId); } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestSortRepository.java b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestSortRepository.java new file mode 100644 index 00000000..2ac3a79e --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestSortRepository.java @@ -0,0 +1,10 @@ +package com.opus.opus.modules.contest.domain.dao; + +import com.opus.opus.modules.contest.domain.ContestSort; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContestSortRepository extends JpaRepository { + + Optional findByContestId(final Long contestId); +} diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java index 3cc966f0..8829d8dc 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,13 @@ public enum ContestExceptionType implements BaseExceptionType { CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."), CONTEST_NAME_ALREADY_EXIST(HttpStatus.CONFLICT, "동일한 대회명이 있습니다."), VOTE_END_PRECEDE_VOTE_START(HttpStatus.BAD_REQUEST, "투표 종료가 투표 시작보다 빠를 수 없습니다."), - CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.") + CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD(HttpStatus.BAD_REQUEST, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다."), + NOT_FOUND_CONTEST_SORT(HttpStatus.NOT_FOUND, "존재하는 팀 정렬이 없습니다"), + ONLY_CUSTOM_MODE_CAN_CHANGE(HttpStatus.FORBIDDEN, "CUSTOM 모드에서만 정렬을 수정할 수 있습니다."), + DUPLICATE_TEAM_ID_IN_SORT_REQUEST(HttpStatus.BAD_REQUEST, "중복된 팀ID가 있습니다."), + DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST(HttpStatus.BAD_REQUEST, "중복된 itemOrder가 있습니다."), + NOT_EXIST_TEAM_IN_CONTEST(HttpStatus.NOT_FOUND, "현재 대회에 소속된 팀이 아닙니다"), + INVALID_CONTEST_SORT_CUSTOM_REQUEST(HttpStatus.BAD_REQUEST, "저장된 팀 개수와 request의 팀 개수가 다릅니다"), ; private final HttpStatus httpStatus; 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 41c7583a..082aa4cc 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 @@ -8,6 +8,7 @@ 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.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,4 +40,7 @@ public void validateAllTeamsDeletedInTrack(final Long trackId) { } } + public List getTeamsOfContest(final Long contestId){ + return teamRepository.findAllByContestId(contestId); + } } diff --git a/src/main/java/com/opus/opus/modules/team/domain/Team.java b/src/main/java/com/opus/opus/modules/team/domain/Team.java index 37f0bbb3..ce0a9a3f 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/Team.java +++ b/src/main/java/com/opus/opus/modules/team/domain/Team.java @@ -96,4 +96,7 @@ private Team(final String teamName, final String projectName, final String profe this.teamMembers = teamMembers; } + public void updateItemOrder(final Integer newOrder) { + this.itemOrder = newOrder; + } } 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 efa5dd0f..bb0ea2ff 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,13 +1,16 @@ 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; @Repository public interface TeamRepository extends JpaRepository { - boolean existsByContestId(Long contestId); + + boolean existsByContestId(final Long contestId); boolean existsByTrackId(final Long trackId); + List findAllByContestId(final Long contestId); } diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamExceptionType.java index bdf2aa90..743f4411 100644 --- a/src/main/java/com/opus/opus/modules/team/exception/TeamExceptionType.java +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamExceptionType.java @@ -7,7 +7,8 @@ public enum TeamExceptionType implements BaseExceptionType { NOT_FOUND_TEAM(HttpStatus.NOT_FOUND, "팀이 존재하지 않습니다."), CONTEST_HAS_TEAM(HttpStatus.CONFLICT, "해당 대회에 속한 팀이 존재합니다."), - TRACK_HAS_TEAM(HttpStatus.CONFLICT, "해당 분과에 속한 팀이 존재합니다."); + TRACK_HAS_TEAM(HttpStatus.CONFLICT, "해당 분과에 속한 팀이 존재합니다."), + INVALID_ITEM_ORDER(HttpStatus.BAD_REQUEST, "적절하지 않은 itemOrder입니다.(최대 팀 개수보다 초과된 itemOrder)"), ; private final HttpStatus httpStatus; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 048602f9..c22078f2 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -4,6 +4,7 @@ DROP TABLE IF EXISTS `contest`; DROP TABLE IF EXISTS `contest_award`; DROP TABLE IF EXISTS `contest_category`; DROP TABLE IF EXISTS `contest_track`; +DROP TABLE IF EXISTS `contest_sort`; DROP TABLE IF EXISTS `file`; DROP TABLE IF EXISTS `member`; DROP TABLE IF EXISTS `member_roles`; @@ -14,7 +15,6 @@ DROP TABLE IF EXISTS `team_contest_award`; DROP TABLE IF EXISTS `team_like`; DROP TABLE IF EXISTS `team_member`; DROP TABLE IF EXISTS `team_member_roles`; -DROP TABLE IF EXISTS `team_sort`; CREATE TABLE `contest` ( `id` bigint NOT NULL AUTO_INCREMENT, @@ -60,6 +60,16 @@ CREATE TABLE `contest_track` ( PRIMARY KEY (`id`) ); +CREATE TABLE `contest_sort` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `mode` enum('ASC','CUSTOM','RANDOM') NOT NULL, + `contest_id` bigint NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_contest_id` (`contest_id`) +); + CREATE TABLE `file` ( `id` bigint NOT NULL AUTO_INCREMENT, `created_at` datetime(6) DEFAULT NULL, @@ -169,12 +179,3 @@ CREATE TABLE `team_member_roles` ( `role` enum('ROLE_팀원','ROLE_팀장') NOT NULL, PRIMARY KEY (`team_member_id`,`role`) ); - -CREATE TABLE `team_sort` ( - `id` bigint NOT NULL AUTO_INCREMENT, - `created_at` datetime(6) DEFAULT NULL, - `updated_at` datetime(6) DEFAULT NULL, - `mode` enum('ASC','CUSTOM','RANDOM') NOT NULL, - `team_id` bigint NOT NULL, - PRIMARY KEY (`id`) -); diff --git a/src/test/java/com/opus/opus/contest/ContestFixture.java b/src/test/java/com/opus/opus/contest/ContestFixture.java index 2063d7d8..722bd723 100644 --- a/src/test/java/com/opus/opus/contest/ContestFixture.java +++ b/src/test/java/com/opus/opus/contest/ContestFixture.java @@ -4,7 +4,6 @@ public class ContestFixture { - public static Contest createContest() { return Contest.builder() .contestName("제 1회 테스트 대회") diff --git a/src/test/java/com/opus/opus/contest/ContestSortFixture.java b/src/test/java/com/opus/opus/contest/ContestSortFixture.java new file mode 100644 index 00000000..6da36d3e --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestSortFixture.java @@ -0,0 +1,11 @@ +package com.opus.opus.contest; + +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestSort; + +public class ContestSortFixture { + + public static ContestSort createContestSort(final Contest contest) { + return ContestSort.builder().contest(contest).build(); + } +} 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 62725729..c536f235 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -1,19 +1,36 @@ package com.opus.opus.contest.application; +import static com.opus.opus.contest.ContestFixture.createContest; +import static com.opus.opus.contest.ContestSortFixture.createContestSort; +import static com.opus.opus.modules.contest.domain.SortType.ASC; +import static com.opus.opus.modules.contest.domain.SortType.CUSTOM; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_TEAM_ID_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.INVALID_CONTEST_SORT_CUSTOM_REQUEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.ONLY_CUSTOM_MODE_CAN_CHANGE; import static com.opus.opus.modules.contest.exception.ContestExceptionType.VOTE_END_PRECEDE_VOTE_START; +import static com.opus.opus.modules.team.exception.TeamExceptionType.INVALID_ITEM_ORDER; +import static com.opus.opus.team.TeamFixture.createTeamWithContestIdAndItemOrder; 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.modules.contest.application.ContestCommandService; +import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest; import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestSort; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestSortRepository; import com.opus.opus.modules.contest.exception.ContestException; +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.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,13 +43,18 @@ public class ContestCommandServiceTest extends IntegrationTest { @Autowired private ContestRepository contestRepository; + @Autowired + private ContestSortRepository contestSortRepository; + @Autowired + private TeamRepository teamRepository; private Contest contest; private static final Integer MAX_VOTES_LIMIT = 5; @BeforeEach void setUp() { - contest = contestRepository.save(ContestFixture.createContestWithCategoryId(1L)); + contest = contestRepository.save(createContest()); + contestSortRepository.save(createContestSort(contest)); } @Test @@ -65,7 +87,9 @@ void setUp() { final LocalDateTime endAt = LocalDateTime.now().plusDays(1); final VoteUpdateRequest request = new VoteUpdateRequest(startAt, endAt); - assertThatThrownBy(() -> {contestCommandService.updateVotePeriod(contest.getId(), request);}) + assertThatThrownBy(() -> { + contestCommandService.updateVotePeriod(contest.getId(), request); + }) .isInstanceOf(ContestException.class) .hasMessage(VOTE_END_PRECEDE_VOTE_START.errorMessage()); } @@ -125,4 +149,102 @@ void setUp() { final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); // 변경 후 값 검증 assertThat(updatedContest.getMaxVotesLimit()).isEqualTo(MAX_VOTES_LIMIT); } -} \ No newline at end of file + + @Test + @DisplayName("[성공] 대회 정렬 설정 변경을 하면 설정이 변경된다.") + void 대회_정렬_설정_변경을_하면_설정이_변경된다() { + final ContestSort beforeContestSort = contestSortRepository.findByContestId(contest.getId()).orElseThrow(); + final ContestSortRequest request = new ContestSortRequest(ASC); + + contestCommandService.updateContestSort(contest.getId(), request); + + final ContestSort afterContestSort = contestSortRepository.findByContestId(contest.getId()).orElseThrow(); + assertThat(afterContestSort.getMode()).isEqualTo(ASC); + assertThat(afterContestSort.getMode()).isNotEqualTo(beforeContestSort); + } + + @Test + @DisplayName("[성공] 팀 수동 정렬을 하면 정렬 순서가 바뀐다.") + void 팀_수동_정렬을_하면_정렬_순서가_바뀐다() { + contestCommandService.updateContestSort(contest.getId(), new ContestSortRequest(CUSTOM)); + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final Team teamTwo = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 2)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 2), + new ContestSortCustomRequest(teamTwo.getId(), 1)); + + contestCommandService.updateContestSortCustom(contest.getId(), requests); + + assertThat(teamOne.getItemOrder()).isEqualTo(2); + assertThat(teamTwo.getItemOrder()).isEqualTo(1); + } + + @Test + @DisplayName("[실패] CUSTOM모드가 아니라면 수동 정렬은 실패한다.") + void CUSTOM모드가_아니라면_수동_정렬은_실패한다() { + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final Team teamTwo = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 2)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 2), + new ContestSortCustomRequest(teamTwo.getId(), 1)); + + assertThatThrownBy(() -> { + contestCommandService.updateContestSortCustom(contest.getId(), requests); + }).isInstanceOf(ContestException.class).hasMessage(ONLY_CUSTOM_MODE_CAN_CHANGE.errorMessage()); + } + + @Test + @DisplayName("[실패] 중복 teamId가 있다면 수동 정렬은 실패한다.") + void 중복_teamId가_있다면_수동_정렬은_실패한다() { + contestCommandService.updateContestSort(contest.getId(), new ContestSortRequest(CUSTOM)); + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 2), + new ContestSortCustomRequest(teamOne.getId(), 1)); + + assertThatThrownBy(() -> { + contestCommandService.updateContestSortCustom(contest.getId(), requests); + }).isInstanceOf(ContestException.class).hasMessage(DUPLICATE_TEAM_ID_IN_SORT_REQUEST.errorMessage()); + } + + @Test + @DisplayName("[실패] 중복 itemOrder가 있다면 수동 정렬은 실패한다.") + void 중복_itemOrder가_있다면_수동_정렬은_실패한다() { + contestCommandService.updateContestSort(contest.getId(), new ContestSortRequest(CUSTOM)); + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final Team teamTwo = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 2)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 1), + new ContestSortCustomRequest(teamTwo.getId(), 1)); + + assertThatThrownBy(() -> { + contestCommandService.updateContestSortCustom(contest.getId(), requests); + }).isInstanceOf(ContestException.class).hasMessage(DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST.errorMessage()); + } + + @Test + @DisplayName("[실패] 요청받은 list size가 팀 개수와 다르면 수동 정렬은 실패한다.") + void 요청받은_list_size가_팀_개수와_다르면_수동_정렬은_실패한다() { + contestCommandService.updateContestSort(contest.getId(), new ContestSortRequest(CUSTOM)); + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final Team teamTwo = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 2)); + teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(),3)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 2), + new ContestSortCustomRequest(teamTwo.getId(), 1)); + + assertThatThrownBy(() -> { + contestCommandService.updateContestSortCustom(contest.getId(), requests); + }).isInstanceOf(ContestException.class).hasMessage(INVALID_CONTEST_SORT_CUSTOM_REQUEST.errorMessage()); + } + + @Test + @DisplayName("[실패] 요청받은 itemOrder가 팀 개수를 넘어가면 수동 정렬은 실패한다.") + void 요청받은_itemOrder가_팀_개수를_넘어가면_수동_정렬은_실패한다() { + final int invalidItemOrder = 99; + contestCommandService.updateContestSort(contest.getId(), new ContestSortRequest(CUSTOM)); + final Team teamOne = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 1)); + final Team teamTwo = teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 2)); + final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 1), + new ContestSortCustomRequest(teamTwo.getId(), invalidItemOrder)); + + assertThatThrownBy(() -> { + contestCommandService.updateContestSortCustom(contest.getId(), requests); + }).isInstanceOf(TeamException.class).hasMessage(INVALID_ITEM_ORDER.errorMessage()); + } +} 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 1d451c52..cb019e64 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -1,7 +1,13 @@ package com.opus.opus.restdocs.docs; +import static com.opus.opus.modules.contest.domain.SortType.ASC; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.DUPLICATE_TEAM_ID_IN_SORT_REQUEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.INVALID_CONTEST_SORT_CUSTOM_REQUEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.ONLY_CUSTOM_MODE_CAN_CHANGE; +import static com.opus.opus.modules.team.exception.TeamExceptionType.INVALID_ITEM_ORDER; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; @@ -24,16 +30,21 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.opus.opus.modules.contest.application.dto.request.ContestSortCustomRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestSortRequest; import com.opus.opus.modules.contest.application.dto.request.ContestVotesLimitRequest; import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; +import com.opus.opus.modules.contest.application.dto.response.ContestSortResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.contest.exception.ContestException; import com.opus.opus.modules.file.exception.FileException; import com.opus.opus.modules.file.exception.FileExceptionType; import com.opus.opus.modules.team.application.dto.ImageResponse; +import com.opus.opus.modules.team.exception.TeamException; import com.opus.opus.restdocs.RestDocsTest; import java.time.LocalDateTime; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -154,7 +165,8 @@ void setUp() { parameterWithName("contestId").description("대회의 고유 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( numberFieldWithPath("maxVotesLimit", "최대 투표 개수") @@ -182,7 +194,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( dateTimeFieldWithPath("voteStartAt", "투표 시작일"), @@ -210,7 +223,8 @@ void setUp() { parameterWithName("contestId").description("존재하지 않는 대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( numberFieldWithPath("maxVotesLimit", "최대 투표 개수") @@ -237,7 +251,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( numberFieldWithPath("maxVotesLimit", "최대 투표 개수") @@ -260,7 +275,8 @@ void setUp() { parameterWithName("contestId").description("대회의 고유 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), responseFields( numberFieldWithPath("maxVotesLimit", "최대 투표 개수") @@ -283,7 +299,8 @@ void setUp() { parameterWithName("contestId").description("존재하지 않는 대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ) )); } @@ -333,7 +350,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestParts( partWithName("image").description("등록할 배너 이미지 (모든 이미지 형식 지원)") @@ -357,8 +375,169 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "admin")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ) )); } + + @Test + @DisplayName("[성공] 유효한 요청이면 대회 정렬 설정 변경은 성공한다.") + void 유효한_요청이면_대회의_정렬_설정_변경은_성공한다() throws Exception { + final ContestSortRequest request = new ContestSortRequest(ASC); + + doNothing().when(contestCommandService).updateContestSort(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andDo(document("update-contest-sort", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + stringFieldWithPath("mode", "수정할 대회 정렬 모드") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 대회 정렬 설정 조회는 성공한다.") + void 유효한_요청이면_대회의_정렬_설정_조회는_성공한다() throws Exception { + final ContestSortResponse response = new ContestSortResponse(ASC); + when(contestQueryService.getContestSort(any())).thenReturn(response); + + mockMvc.perform(get("/contests/{contestId}/sort", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("get-contest-sort", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + responseFields( + stringFieldWithPath("currentMode", "현재 적용되어 있는 모드 정보") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 대회 수동 정렬 설정 순서 저장은 성공한다.") + void 유효한_요청이면_대회_수동_정렬_순서_저장은_성공한다() throws Exception { + final List requests = List.of(new ContestSortCustomRequest(1L, 1), + new ContestSortCustomRequest(2L, 3), new ContestSortCustomRequest(3L, 2)); + + doNothing().when(contestCommandService).updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requests))) + .andExpect(status().isNoContent()) + .andDo(document("update-contest-sort-custom", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + arrayFieldWithPath("[]", "정렬 순서를 담은 팀 배열(모든 팀 다 보내주세요)"), + numberFieldWithPath("[].teamId", "정렬 순서를 변경할 팀 ID"), + numberFieldWithPath("[].itemOrder", "팀의 정렬 순서 (1부터 팀 개수까지)") + ) + )); + } + + @Test + @DisplayName("[실패] CUSTOM모드가 아니라면 수동 정렬 저장은 실패한다.") + void CUSTOM모드가_아니라면_수동_정렬_저장은_실패한다() throws Exception { + willThrow(new ContestException(ONLY_CUSTOM_MODE_CAN_CHANGE)).given(contestCommandService) + .updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(document("update-contest-sort-custom-fail-mode")); + } + + @Test + @DisplayName("[실패] request에 중복 teamId가 있으면 수동 정렬 저장은 실패한다.") + void request에_중복_teamId가_있으면_수동_정렬_저장은_실패한다() throws Exception { + final List requests = List.of(new ContestSortCustomRequest(1L, 1), + new ContestSortCustomRequest(2L, 3), new ContestSortCustomRequest(1L, 2)); + + willThrow(new ContestException(DUPLICATE_TEAM_ID_IN_SORT_REQUEST)).given(contestCommandService) + .updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requests))) + .andExpect(status().isBadRequest()) + .andDo(document("update-contest-sort-custom-fail-duplicate-teamId", + requestFields( + arrayFieldWithPath("[]", "정렬 순서를 담은 팀 배열"), + numberFieldWithPath("[].teamId", "팀 ID(중복 존재)"), + numberFieldWithPath("[].itemOrder", "팀의 정렬 순서") + ) + )); + } + + @Test + @DisplayName("[실패] request에 중복 itemOrder가 있으면 수동 정렬 저장은 실패한다.") + void request에_중복_itemOrder가_있으면_수동_정렬_저장은_실패한다() throws Exception { + final List requests = List.of(new ContestSortCustomRequest(1L, 1), + new ContestSortCustomRequest(2L, 3), new ContestSortCustomRequest(3L, 1)); + + willThrow(new ContestException(DUPLICATE_ITEM_ORDER_IN_SORT_REQUEST)).given(contestCommandService) + .updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requests))) + .andExpect(status().isBadRequest()) + .andDo(document("update-contest-sort-custom-fail-duplicate-itemOrder", + requestFields( + arrayFieldWithPath("[]", "정렬 순서를 담은 팀 배열"), + numberFieldWithPath("[].teamId", "팀 ID"), + numberFieldWithPath("[].itemOrder", "팀의 정렬 순서(중복 존재)") + ) + )); + } + + @Test + @DisplayName("[실패] 요청 팀 개수와 저장된 팀 개수가 다르면 수동 정렬 저장은 실패한다.") + void 요청_팀_개수와_저장된_팀_개수가_다르면_수동_정렬_저장은_실패한다() throws Exception { + willThrow(new ContestException(INVALID_CONTEST_SORT_CUSTOM_REQUEST)).given(contestCommandService) + .updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(document("update-contest-sort-custom-fail-different-size")); + } + + @Test + @DisplayName("[실패] 저장된 팀 개수보다 itemOrder가 크면 수동 정렬 저장은 실패한다.") + void 저장된_팀_개수보다_itemOrder가_크면_수동_정렬_저장은_실패한다() throws Exception { + willThrow(new TeamException(INVALID_ITEM_ORDER)).given(contestCommandService) + .updateContestSortCustom(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(document("update-contest-sort-custom-fail-over-itemOrder")); + } } diff --git a/src/test/java/com/opus/opus/team/TeamFixture.java b/src/test/java/com/opus/opus/team/TeamFixture.java index 3a5c34d2..a0cde946 100644 --- a/src/test/java/com/opus/opus/team/TeamFixture.java +++ b/src/test/java/com/opus/opus/team/TeamFixture.java @@ -20,4 +20,20 @@ public static Team createTeam() { .teamMembers(new ArrayList<>()) .build(); } + + public static Team createTeamWithContestIdAndItemOrder(final Long contestId, final int itemOrder) { + return Team.builder() + .teamName("팀 옵스") + .projectName("옵스 프로젝트") + .professorName("김교수") + .overview("이 프로젝트는 옵스 프로젝트입니다.") + .githubPath("http://github.com/example") + .productionPath("http://production.example.com") + .youTubePath("http://youtube.com/example") + .contestId(contestId) + .trackId(1L) + .itemOrder(itemOrder) + .teamMembers(new ArrayList<>()) + .build(); + } }