Conversation
There was a problem hiding this comment.
Pull request overview
대회(Contest) 도메인에 “정렬 모드/수동 정렬” 기능을 추가하고, 이를 변경/조회하는 관리자 API 및 문서·테스트를 함께 보강한 PR입니다. 이슈 #29(기본값 RANDOM) 요구사항을 반영해 대회 생성 시 정렬 설정이 함께 생성되도록 확장했습니다.
Changes:
ContestSort(정렬 설정) 엔티티/리포지토리/Convenience를 추가하고, 대회 생성 시 기본 정렬 설정을 자동 생성- 관리자용 대회 정렬 API(모드 변경/조회, CUSTOM 수동 정렬 저장) 및 Query/Command 로직 추가
- RestDocs/통합 테스트/스키마 및 Team itemOrder 업데이트 지원 코드 추가
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java | 대회 정렬 설정 생성/변경 및 CUSTOM 수동 정렬 저장 로직 추가 |
| src/main/java/com/opus/opus/modules/contest/api/ContestController.java | 대회 정렬 관련 관리자 API 엔드포인트 추가 |
| src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java | 대회 정렬 설정 조회 기능 추가 |
| src/main/java/com/opus/opus/modules/contest/domain/ContestSort.java | Contest 기준 정렬 설정 엔티티로 정리 및 모드 업데이트 메서드 추가 |
| src/main/java/com/opus/opus/modules/contest/domain/SortType.java | SortType의 올바른 패키지(contest)로 이동 |
| src/main/java/com/opus/opus/modules/contest/exception/ContestExceptionType.java | 정렬 기능 관련 예외 타입 추가 |
| src/main/java/com/opus/opus/modules/contest/domain/dao/ContestSortRepository.java | ContestSort 조회용 리포지토리 추가 |
| src/main/java/com/opus/opus/modules/contest/domain/dao/ContestRepository.java | 대회 row lock(PESSIMISTIC_WRITE) 조회 메서드 추가 |
| src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java | 락 조회 convenience 메서드 추가(MANDATORY) |
| src/main/java/com/opus/opus/modules/contest/application/convenience/ContestSortConvenience.java | ContestSort 존재 검증/조회 convenience 추가 |
| src/main/java/com/opus/opus/modules/team/domain/Team.java | itemOrder 업데이트 메서드 추가 |
| src/main/java/com/opus/opus/modules/team/domain/dao/TeamRepository.java | contestId 기반 팀 목록 조회 메서드 추가 |
| src/main/java/com/opus/opus/modules/team/application/convenience/TeamConvenience.java | 대회 소속 팀 목록 조회 메서드 추가 |
| src/main/java/com/opus/opus/modules/team/exception/TeamExceptionType.java | itemOrder 범위 검증용 예외 추가 |
| src/main/resources/schema.sql | contest_sort 테이블 추가 및 team_sort 제거 |
| src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java | 정렬 API RestDocs 테스트 추가 |
| src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java | 정렬 기능 통합 테스트 추가 |
| src/test/java/com/opus/opus/contest/ContestSortFixture.java | ContestSort 테스트 fixture 추가 |
| src/test/java/com/opus/opus/team/TeamFixture.java | contestId/itemOrder 지정 팀 fixture 추가 |
| src/main/java/com/opus/opus/docs/asciidoc/contest.adoc | 정렬 관리 API 문서 추가 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| final ContestSort afterContestSort = contestSortRepository.findByContestId(contest.getId()).orElseThrow(); | ||
| assertThat(afterContestSort.getMode()).isEqualTo(ASC); | ||
| assertThat(afterContestSort.getMode()).isNotEqualTo(beforeContestSort); |
There was a problem hiding this comment.
assertThat(afterContestSort.getMode()).isNotEqualTo(beforeContestSort); 는 SortType과 ContestSort를 비교하고 있어 항상 true가 되어 테스트가 의미를 잃습니다. 변경 전/후를 비교하려면 beforeContestSort.getMode() 또는 beforeContestSort.getId() 등 동일 타입/값을 비교하도록 수정해야 합니다.
| assertThat(afterContestSort.getMode()).isNotEqualTo(beforeContestSort); | |
| assertThat(afterContestSort.getMode()).isNotEqualTo(beforeContestSort.getMode()); |
| 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의 팀 개수가 다릅니다"), |
There was a problem hiding this comment.
NOT_FOUND_CONTEST_SORT/NOT_EXIST_TEAM_IN_CONTEST/INVALID_CONTEST_SORT_CUSTOM_REQUEST 등의 errorMessage가 '팀 정렬'로 표기되어 있거나 문장 끝 마침표가 누락되어 기존 메시지들과 톤이 불일치합니다. 특히 NOT_FOUND_CONTEST_SORT는 현재 기능이 '대회 정렬'이므로 메시지 내용 자체가 잘못되어 클라이언트/문서에 혼동을 줄 수 있습니다.
| 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의 팀 개수가 다릅니다"), | |
| 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, "저장된 팀 개수와 요청한 팀 개수가 다릅니다."), |
| @Secured("ROLE_관리자") | ||
| @PutMapping("/{contestId}/sort/custom") | ||
| public ResponseEntity<Void> updateContestSortCustom(@PathVariable final Long contestId, | ||
| @RequestBody final List<@Valid ContestSortCustomRequest> requests) { |
There was a problem hiding this comment.
updateContestSortCustom의 request body는 List 타입인데 파라미터에 @Valid가 없어서(현재는 List<@Valid ...>만 사용) Bean Validation이 실행되지 않을 수 있습니다. Spring MVC에서 @RequestBody 검증을 보장하려면 파라미터 자체에 @Valid(또는 @Validated)를 추가해 요소의 @NotNull 등이 실제로 검증되도록 해주세요.
| @RequestBody final List<@Valid ContestSortCustomRequest> requests) { | |
| @Valid @RequestBody final List<ContestSortCustomRequest> requests) { |
| } | ||
| } | ||
|
|
||
| public List<Team> getTeamsOfContest(final Long contestId){ |
There was a problem hiding this comment.
메서드 선언부에서 { 앞 공백이 빠져 있어(getTeamsOfContest(final Long contestId){) 같은 파일의 다른 메서드들과 포맷이 불일치합니다. 포맷터/컨벤션에 맞게 공백을 추가해주세요.
| public List<Team> getTeamsOfContest(final Long contestId){ | |
| public List<Team> getTeamsOfContest(final Long contestId) { |
| 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()) |
There was a problem hiding this comment.
CUSTOM모드가_아니라면_수동_정렬_저장은_실패한다 테스트는 request body 없이 application/json으로 호출하고 있어 컨트롤러에서 JSON 파싱 단계에서 400이 나고 서비스 stub(ONLY_CUSTOM_MODE_CAN_CHANGE)가 실행되지 않을 가능성이 큽니다. 또한 ONLY_CUSTOM_MODE_CAN_CHANGE는 HttpStatus.FORBIDDEN(403)인데 여기서는 400을 기대하고 있어 실제 예외 매핑(ExceptionAdvice)과 불일치합니다. (유효한 JSON 배열을 보내고 상태 코드를 403으로 맞추거나, 예외 타입의 status를 조정해주세요.)
| 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()) | |
| final List<ContestSortCustomRequest> requests = List.of( | |
| new ContestSortCustomRequest(1L, 1), | |
| new ContestSortCustomRequest(2L, 2) | |
| ); | |
| 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) | |
| .content(objectMapper.writeValueAsString(requests))) | |
| .andExpect(status().isForbidden()) |
| 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")); |
There was a problem hiding this comment.
요청_팀_개수와_저장된_팀_개수가_다르면_수동_정렬_저장은_실패한다 테스트도 request body 없이 JSON 요청을 보내고 있어(빈 body) 서비스의 INVALID_CONTEST_SORT_CUSTOM_REQUEST 예외가 아니라 HttpMessageNotReadable 등으로 400이 발생할 수 있습니다. 문서화하려는 실패 케이스가 서비스 레벨 검증이라면 최소한 [] 또는 유효한 JSON 배열을 body에 포함시켜 컨트롤러가 정상적으로 서비스까지 도달하도록 해주세요.
| 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")); |
There was a problem hiding this comment.
저장된_팀_개수보다_itemOrder가_크면_수동_정렬_저장은_실패한다 테스트도 request body 없이 JSON 요청을 보내고 있어 서비스의 INVALID_ITEM_ORDER 예외 대신 컨트롤러 단계 파싱 오류로 400이 날 수 있습니다. 유효한 JSON 배열 body를 포함시켜 실제로 TeamException(INVALID_ITEM_ORDER) 경로를 문서화하도록 수정하는 편이 안전합니다.
| 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")); | |
| final List<ContestSortCustomRequest> requests = List.of( | |
| new ContestSortCustomRequest(1L, 2), | |
| new ContestSortCustomRequest(2L, 3) | |
| ); | |
| 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) | |
| .content(objectMapper.writeValueAsString(requests))) | |
| .andExpect(status().isBadRequest()) | |
| .andDo(document("update-contest-sort-custom-fail-over-itemOrder", | |
| requestFields( | |
| arrayFieldWithPath("[]", "정렬 순서를 담은 팀 배열"), | |
| numberFieldWithPath("[].teamId", "팀 ID"), | |
| numberFieldWithPath("[].itemOrder", "저장된 팀 개수보다 큰 팀의 정렬 순서") | |
| ) | |
| )); |
sjmoon00
left a comment
There was a problem hiding this comment.
수고하셨습니다~
검증을 엄청 꼼꼼하게 하신 부분이 잘 느껴집니다
| mockMvc.perform(put("/contests/{contestId}/sort/custom", 1) | ||
| .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) | ||
| .contentType(MediaType.APPLICATION_JSON)) |
There was a problem hiding this comment.
.content() 설정이 빠져 있습니다
본문 없이 요청을 보내면 비즈니스 로직에 도달하기 전 예외가 발생하여 테스트가 의도와 다르게 작동하지 않을까 생각합니다
| .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) | ||
| .contentType(MediaType.APPLICATION_JSON)) |
pykido
left a comment
There was a problem hiding this comment.
수고하셨습니다!
검증 부분을 꼼꼼하게 잘 작성해주셨네요! 간단한 코멘트 확인 부탁드립니다 ㅎ
| for (ContestSortCustomRequest r : requests) { | ||
| final int order = r.itemOrder(); | ||
| if (order < 1 || order > teamCount) { | ||
| throw new TeamException(INVALID_ITEM_ORDER); |
There was a problem hiding this comment.
Contest 관련 로직에서 TeamException을 던지는게 조금 어색한 거 같습니다!
| @Builder | ||
| private TeamSort(final Team team) { | ||
| private ContestSort(final Contest contest) { | ||
| this.mode = RANDOM; |
| final List<Team> teams = teamConvenience.getTeamsOfContest(contestId); | ||
| validateRequestSizeMatchesTeams(requests, teams); | ||
| validateItemOrderRange(requests, teams.size()); | ||
| validateDuplicateItemOrders(requests); |
There was a problem hiding this comment.
이 부분도 DB 조회 전에 검증 가능할 거 같은데 validateDupliacteTeamIds아래로 올리면 어떨까요?
🔥 연관된 이슈
close: #29
📜 작업 내용
💬 리뷰 요구사항
✨ 기타