From f66feedceed3991b8679d4b151286afb522a6a70 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 03:49:10 +0900 Subject: [PATCH 01/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=ED=8C=80?= =?UTF-8?q?=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=88=98=EC=A0=95=20controlle?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opus/modules/contest/api/ContestController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 3c0ebee6..be5e372e 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 @@ -2,8 +2,10 @@ import com.opus.opus.modules.contest.application.ContestCommandService; import com.opus.opus.modules.contest.application.ContestQueryService; +import com.opus.opus.modules.contest.application.ContestTeamDetailTemplateCommandService; 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.TeamDetailTemplateRequest; 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; @@ -21,6 +23,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -35,6 +38,7 @@ public class ContestController { private final ContestCommandService contestCommandService; private final ContestQueryService contestQueryService; + private final ContestTeamDetailTemplateCommandService contestTeamDetailTemplateCommandService; @GetMapping("/{contestId}/image/banner") public ResponseEntity getContestBanner(@PathVariable final Long contestId) { @@ -101,4 +105,12 @@ public ResponseEntity> getCurrentContests() { List responses = contestQueryService.getCurrentContests(); return ResponseEntity.ok(responses); } + + @PutMapping("/{contestId}/team-detail-template") + @Secured("ROLE_관리자") + public ResponseEntity updateTeamDetailTemplate(@PathVariable final Long contestId, + @Valid @RequestBody final TeamDetailTemplateRequest request) { + contestTeamDetailTemplateCommandService.updateTemplate(contestId, request); + return ResponseEntity.noContent().build(); + } } From 67da5767654ea94ffeeb5e8a4a4023d345322d39 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 04:02:52 +0900 Subject: [PATCH 02/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=ED=8C=80?= =?UTF-8?q?=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=8A=A4=ED=82=A4=EB=A7=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest/api/ContestController.java | 10 +- .../contest/domain/ContestTeamTemplate.java | 145 ++++++++++++++++++ .../domain/ContestTeamTemplateFieldType.java | 18 +++ src/main/resources/schema.sql | 23 +++ 4 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java create mode 100644 src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java 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 be5e372e..7ef9b893 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 @@ -2,10 +2,10 @@ import com.opus.opus.modules.contest.application.ContestCommandService; import com.opus.opus.modules.contest.application.ContestQueryService; -import com.opus.opus.modules.contest.application.ContestTeamDetailTemplateCommandService; +import com.opus.opus.modules.contest.application.ContestTeamTemplateCommandService; 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.TeamDetailTemplateRequest; +import com.opus.opus.modules.contest.application.dto.request.TeamTemplateRequest; 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; @@ -38,7 +38,7 @@ public class ContestController { private final ContestCommandService contestCommandService; private final ContestQueryService contestQueryService; - private final ContestTeamDetailTemplateCommandService contestTeamDetailTemplateCommandService; + private final ContestTeamTemplateCommandService contestTeamTemplateCommandService; @GetMapping("/{contestId}/image/banner") public ResponseEntity getContestBanner(@PathVariable final Long contestId) { @@ -109,8 +109,8 @@ public ResponseEntity> getCurrentContests() { @PutMapping("/{contestId}/team-detail-template") @Secured("ROLE_관리자") public ResponseEntity updateTeamDetailTemplate(@PathVariable final Long contestId, - @Valid @RequestBody final TeamDetailTemplateRequest request) { - contestTeamDetailTemplateCommandService.updateTemplate(contestId, request); + @Valid @RequestBody final TeamTemplateRequest request) { + contestTeamTemplateCommandService.updateTemplate(contestId, request); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java new file mode 100644 index 00000000..3b7ae5cb --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java @@ -0,0 +1,145 @@ +package com.opus.opus.modules.contest.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.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("is_deleted = false") +@SQLDelete(sql = "UPDATE contest_team_template SET is_deleted = true WHERE id = ?") +public class ContestTeamTemplate extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = LAZY) + @JoinColumn(name = "contest_id", nullable = false, unique = true) + private Contest contest; + + @Column(name = "division", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType division; + + @Column(name = "project_name", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType projectName; + + @Column(name = "team_name", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType teamName; + + @Column(name = "leader", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType leader; + + @Column(name = "team_members", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType teamMembers; + + @Column(name = "professor", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType professor; + + @Column(name = "github_path", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType githubPath; + + @Column(name = "youtube_path", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType youtubePath; + + @Column(name = "production_path", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType productionPath; + + @Column(name = "overview", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType overview; + + @Column(name = "poster", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType poster; + + @Column(name = "images", nullable = false) + @Enumerated(EnumType.STRING) + private ContestTeamTemplateFieldType images; + + @Column(nullable = false) + private Boolean isDeleted; + + @Builder + private ContestTeamTemplate( + final Contest contest, + final ContestTeamTemplateFieldType division, + final ContestTeamTemplateFieldType projectName, + final ContestTeamTemplateFieldType teamName, + final ContestTeamTemplateFieldType leader, + final ContestTeamTemplateFieldType teamMembers, + final ContestTeamTemplateFieldType professor, + final ContestTeamTemplateFieldType githubPath, + final ContestTeamTemplateFieldType youtubePath, + final ContestTeamTemplateFieldType productionPath, + final ContestTeamTemplateFieldType overview, + final ContestTeamTemplateFieldType poster, + final ContestTeamTemplateFieldType images) { + this.contest = contest; + this.division = division; + this.projectName = projectName; + this.teamName = teamName; + this.leader = leader; + this.teamMembers = teamMembers; + this.professor = professor; + this.githubPath = githubPath; + this.youtubePath = youtubePath; + this.productionPath = productionPath; + this.overview = overview; + this.poster = poster; + this.images = images; + this.isDeleted = false; + } + + public void updateTemplate( + final ContestTeamTemplateFieldType division, + final ContestTeamTemplateFieldType projectName, + final ContestTeamTemplateFieldType teamName, + final ContestTeamTemplateFieldType leader, + final ContestTeamTemplateFieldType teamMembers, + final ContestTeamTemplateFieldType professor, + final ContestTeamTemplateFieldType githubPath, + final ContestTeamTemplateFieldType youtubePath, + final ContestTeamTemplateFieldType productionPath, + final ContestTeamTemplateFieldType overview, + final ContestTeamTemplateFieldType poster, + final ContestTeamTemplateFieldType images) { + this.division = division; + this.projectName = projectName; + this.teamName = teamName; + this.leader = leader; + this.teamMembers = teamMembers; + this.professor = professor; + this.githubPath = githubPath; + this.youtubePath = youtubePath; + this.productionPath = productionPath; + this.overview = overview; + this.poster = poster; + this.images = images; + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java new file mode 100644 index 00000000..661ac56d --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java @@ -0,0 +1,18 @@ +package com.opus.opus.modules.contest.domain; + +import lombok.Getter; + +@Getter +public enum ContestTeamTemplateFieldType { + REQUIRED(1), + OPTIONAL(2), + HIDDEN(3), + ; + + private final long id; + + ContestTeamTemplateFieldType(final long id) { + this.id = id; + } +} + diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 6d06ffbd..8bba4c45 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -3,6 +3,7 @@ USE opus; DROP TABLE IF EXISTS `contest`; DROP TABLE IF EXISTS `contest_award`; DROP TABLE IF EXISTS `contest_category`; +DROP TABLE IF EXISTS `contest_team_template`; DROP TABLE IF EXISTS `contest_track`; DROP TABLE IF EXISTS `file`; DROP TABLE IF EXISTS `member`; @@ -50,6 +51,28 @@ CREATE TABLE `contest_category` ( PRIMARY KEY (`id`) ); +CREATE TABLE `contest_team_template` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `created_at` datetime(6) DEFAULT NULL, + `updated_at` datetime(6) DEFAULT NULL, + `contest_id` bigint NOT NULL, + `division` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `project_name` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `team_name` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `leader` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `team_members` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `professor` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `github_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `youtube_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `production_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `overview` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `poster` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `images` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `is_deleted` bit(1) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_contest_team_template_contest_id` (`contest_id`) +); + CREATE TABLE `contest_track` ( `id` bigint NOT NULL AUTO_INCREMENT, `created_at` datetime(6) DEFAULT NULL, From 669834396acbb3ee09c3b1a7fce1b97cfd5fa763 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 04:15:39 +0900 Subject: [PATCH 03/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=8B=9C=20=ED=8C=80=20=ED=85=9C=ED=94=8C=EB=A6=BF=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ContestCommandService.java | 5 + .../ContestTeamTemplateConvenience.java | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java 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 69790324..8c1a8aad 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 @@ -11,6 +11,7 @@ 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.ContestTeamTemplateConvenience; import com.opus.opus.modules.contest.application.dto.request.ContestRequest; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse; import com.opus.opus.modules.contest.application.dto.response.ContestResponse; @@ -40,6 +41,7 @@ public class ContestCommandService { private final ContestConvenience contestConvenience; private final ContestCategoryConvenience contestCategoryConvenience; private final TeamConvenience teamConvenience; + private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; private final FileStorageUtil fileStorageUtil; @@ -71,6 +73,9 @@ public ContestResponse createContest(final ContestRequest request) { .build(); contestRepository.save(contest); + // 템플릿 자동 생성 + contestTeamTemplateConvenience.createTemplate(contest, contestCategory.getCategoryName()); + return ContestResponse.from(contest, contestCategory.getCategoryName()); } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java new file mode 100644 index 00000000..2e024e49 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java @@ -0,0 +1,102 @@ +package com.opus.opus.modules.contest.application.convenience; + +import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.HIDDEN; +import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.OPTIONAL; +import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.REQUIRED; +import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; + +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; +import com.opus.opus.modules.contest.domain.dao.ContestTeamTemplateRepository; +import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; +import java.util.HashMap; +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 ContestTeamTemplateConvenience { + + private final ContestTeamTemplateRepository contestTeamTemplateRepository; + + public ContestTeamTemplate getValidateExistTemplate(final Long contestId) { + return contestTeamTemplateRepository.findByContestId(contestId) + .orElseThrow(() -> new ContestTeamTemplateException(NOT_FOUND_TEMPLATE)); + } + + public void createTemplate(final Contest contest, final String categoryName) { + final Map settings = getTemplateDefaultSettings(categoryName); + + ContestTeamTemplate template = ContestTeamTemplate.builder() + .contest(contest) + .division(settings.get("division")) + .projectName(settings.get("projectName")) + .teamName(settings.get("teamName")) + .leader(settings.get("leader")) + .teamMembers(settings.get("teamMembers")) + .professor(settings.get("professor")) + .githubPath(settings.get("githubPath")) + .youtubePath(settings.get("youtubePath")) + .productionPath(settings.get("productionPath")) + .overview(settings.get("overview")) + .poster(settings.get("poster")) + .images(settings.get("images")) + .build(); + + contestTeamTemplateRepository.save(template); + } + + private Map getTemplateDefaultSettings(final String categoryName) { + + Map map = new HashMap<>(); + + if (categoryName.contains("창의융합")) { + map.put("division", REQUIRED); + map.put("projectName", REQUIRED); + map.put("teamName", REQUIRED); + map.put("leader", REQUIRED); + map.put("teamMembers", REQUIRED); + map.put("professor", HIDDEN); + map.put("githubPath", REQUIRED); + map.put("youtubePath", OPTIONAL); + map.put("productionPath", OPTIONAL); + map.put("overview", REQUIRED); + map.put("poster", REQUIRED); + map.put("images", REQUIRED); + + } else if (categoryName.contains("캡스톤")) { + map.put("division", REQUIRED); + map.put("projectName", REQUIRED); + map.put("teamName", REQUIRED); + map.put("leader", REQUIRED); + map.put("teamMembers", REQUIRED); + map.put("professor", REQUIRED); + map.put("githubPath", REQUIRED); + map.put("youtubePath", REQUIRED); + map.put("productionPath", OPTIONAL); + map.put("overview", REQUIRED); + map.put("poster", OPTIONAL); + map.put("images", REQUIRED); + + } else { + map.put("division", OPTIONAL); + map.put("projectName", OPTIONAL); + map.put("teamName", OPTIONAL); + map.put("leader", OPTIONAL); + map.put("teamMembers", OPTIONAL); + map.put("professor", OPTIONAL); + map.put("githubPath", OPTIONAL); + map.put("youtubePath", OPTIONAL); + map.put("productionPath", OPTIONAL); + map.put("overview", OPTIONAL); + map.put("poster", OPTIONAL); + map.put("images", OPTIONAL); + } + return map; + } + +} From 896ef821ce767ee6f0d044fb81944ec73930eee6 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 04:18:12 +0900 Subject: [PATCH 04/24] =?UTF-8?q?feat=20:=20=ED=8C=80=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=98=88=EC=99=B8=20=EC=83=81=ED=99=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContestTeamTemplateException.java | 24 +++++++++++++++++ .../ContestTeamTemplateExceptionType.java | 27 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java create mode 100644 src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java new file mode 100644 index 00000000..dadc8cd3 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java @@ -0,0 +1,24 @@ +package com.opus.opus.modules.contest.exception; + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class ContestTeamTemplateException extends BaseException { + private final ContestTeamTemplateExceptionType exceptionType; + + public ContestTeamTemplateException(final ContestTeamTemplateExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + public ContestTeamTemplateException(final ContestTeamTemplateExceptionType exceptionType, final String message) { + super(message); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} + diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java new file mode 100644 index 00000000..0f49c438 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java @@ -0,0 +1,27 @@ +package com.opus.opus.modules.contest.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum ContestTeamTemplateExceptionType implements BaseExceptionType { + NOT_FOUND_TEMPLATE(HttpStatus.NOT_FOUND, "템플릿을 찾을 수 없습니다."), + INVALID_TEMPLATE_FIELD_TYPE(HttpStatus.BAD_REQUEST, "TemplateFieldType은 REQUIRED, OPTIONAL, HIDDEN 중 하나입니다."); + + private final HttpStatus httpStatus; + private final String errorMessage; + + ContestTeamTemplateExceptionType(final HttpStatus httpStatus, final String errorMessage) { + this.httpStatus = httpStatus; + this.errorMessage = errorMessage; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return errorMessage; + } +} From 03bec6344f543d37bb5e9feef5817efb8286119f Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 04:21:00 +0900 Subject: [PATCH 05/24] =?UTF-8?q?feat=20:=20=ED=8C=80=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=88=98=EC=A0=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContestTeamTemplateCommandService.java | 40 +++++++++++++++++++ .../dto/request/TeamTemplateRequest.java | 33 +++++++++++++++ .../dao/ContestTeamTemplateRepository.java | 11 +++++ 3 files changed, 84 insertions(+) create mode 100644 src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java create mode 100644 src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java new file mode 100644 index 00000000..e812f914 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java @@ -0,0 +1,40 @@ +package com.opus.opus.modules.contest.application; + +import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; +import com.opus.opus.modules.contest.application.dto.request.TeamTemplateRequest; +import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class ContestTeamTemplateCommandService { + + private final ContestConvenience contestConvenience; + private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; + + public void updateTemplate(final Long contestId, final TeamTemplateRequest request) { + + contestConvenience.getValidateExistContest(contestId); + final ContestTeamTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate( + contestId); + + template.updateTemplate( + request.division(), + request.projectName(), + request.teamName(), + request.leader(), + request.teamMembers(), + request.professor(), + request.githubPath(), + request.youtubePath(), + request.productionPath(), + request.overview(), + request.poster(), + request.images() + ); + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java new file mode 100644 index 00000000..4649c000 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java @@ -0,0 +1,33 @@ +package com.opus.opus.modules.contest.application.dto.request; + +import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; +import jakarta.validation.constraints.NotNull; + +public record TeamTemplateRequest( + @NotNull(message = "분과 설정은 필수입니다.") + ContestTeamTemplateFieldType division, + @NotNull(message = "프로젝트명 설정은 필수입니다.") + ContestTeamTemplateFieldType projectName, + @NotNull(message = "팀명 설정은 필수입니다.") + ContestTeamTemplateFieldType teamName, + @NotNull(message = "팀장 설정은 필수입니다.") + ContestTeamTemplateFieldType leader, + @NotNull(message = "팀원 설정은 필수입니다.") + ContestTeamTemplateFieldType teamMembers, + @NotNull(message = "지도 교수 설정은 필수입니다.") + ContestTeamTemplateFieldType professor, + @NotNull(message = "GitHub 링크 설정은 필수입니다.") + ContestTeamTemplateFieldType githubPath, + @NotNull(message = "YouTube 링크 설정은 필수입니다.") + ContestTeamTemplateFieldType youtubePath, + @NotNull(message = "배포 링크 설정은 필수입니다.") + ContestTeamTemplateFieldType productionPath, + @NotNull(message = "프로젝트 개요 설정은 필수입니다.") + ContestTeamTemplateFieldType overview, + @NotNull(message = "포스터 설정은 필수입니다.") + ContestTeamTemplateFieldType poster, + @NotNull(message = "이미지 설정은 필수입니다.") + ContestTeamTemplateFieldType images +) { +} + diff --git a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java new file mode 100644 index 00000000..db4d3042 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.contest.domain.dao; + +import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContestTeamTemplateRepository extends JpaRepository { + + Optional findByContestId(final Long contestId); + +} From c8000abd9d828f92d410f8b0a86337dabda0d1dd Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 1 Jan 2026 04:24:27 +0900 Subject: [PATCH 06/24] =?UTF-8?q?feat=20:=20=ED=8C=80=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest/api/ContestController.java | 11 +++++- .../ContestTeamTemplateCommandService.java | 2 +- .../ContestTeamTemplateQueryService.java | 24 ++++++++++++ .../dto/response/TeamTemplateResponse.java | 38 +++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java 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 7ef9b893..5e862692 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 @@ -3,12 +3,14 @@ import com.opus.opus.modules.contest.application.ContestCommandService; import com.opus.opus.modules.contest.application.ContestQueryService; import com.opus.opus.modules.contest.application.ContestTeamTemplateCommandService; +import com.opus.opus.modules.contest.application.ContestTeamTemplateQueryService; 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.TeamTemplateRequest; 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.TeamTemplateResponse; import com.opus.opus.modules.team.application.dto.ImageResponse; import jakarta.validation.Valid; import java.util.List; @@ -39,6 +41,7 @@ public class ContestController { private final ContestCommandService contestCommandService; private final ContestQueryService contestQueryService; private final ContestTeamTemplateCommandService contestTeamTemplateCommandService; + private final ContestTeamTemplateQueryService contestTeamTemplateQueryService; @GetMapping("/{contestId}/image/banner") public ResponseEntity getContestBanner(@PathVariable final Long contestId) { @@ -106,11 +109,17 @@ public ResponseEntity> getCurrentContests() { return ResponseEntity.ok(responses); } + @GetMapping("/{contestId}/team-detail-template") + public ResponseEntity getTeamDetailTemplate(@PathVariable final Long contestId) { + TeamTemplateResponse response = contestTeamTemplateQueryService.getTeamTemplate(contestId); + return ResponseEntity.ok(response); + } + @PutMapping("/{contestId}/team-detail-template") @Secured("ROLE_관리자") public ResponseEntity updateTeamDetailTemplate(@PathVariable final Long contestId, @Valid @RequestBody final TeamTemplateRequest request) { - contestTeamTemplateCommandService.updateTemplate(contestId, request); + contestTeamTemplateCommandService.updateTeamTemplate(contestId, request); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java index e812f914..0c5c84f0 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java @@ -16,7 +16,7 @@ public class ContestTeamTemplateCommandService { private final ContestConvenience contestConvenience; private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; - public void updateTemplate(final Long contestId, final TeamTemplateRequest request) { + public void updateTeamTemplate(final Long contestId, final TeamTemplateRequest request) { contestConvenience.getValidateExistContest(contestId); final ContestTeamTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate( diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java new file mode 100644 index 00000000..5a4cd6f8 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java @@ -0,0 +1,24 @@ +package com.opus.opus.modules.contest.application; + +import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; +import com.opus.opus.modules.contest.application.dto.response.TeamTemplateResponse; +import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContestTeamTemplateQueryService { + + private final ContestConvenience contestConvenience; + private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; + + public TeamTemplateResponse getTeamTemplate(final Long contestId) { + contestConvenience.getValidateExistContest(contestId); + final ContestTeamTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate(contestId); + return TeamTemplateResponse.from(template); + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java new file mode 100644 index 00000000..0c704e81 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java @@ -0,0 +1,38 @@ +package com.opus.opus.modules.contest.application.dto.response; + +import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; + +public record TeamTemplateResponse( + Long contestId, + ContestTeamTemplateFieldType division, + ContestTeamTemplateFieldType projectName, + ContestTeamTemplateFieldType teamName, + ContestTeamTemplateFieldType leader, + ContestTeamTemplateFieldType teamMembers, + ContestTeamTemplateFieldType professor, + ContestTeamTemplateFieldType githubPath, + ContestTeamTemplateFieldType youtubePath, + ContestTeamTemplateFieldType productionPath, + ContestTeamTemplateFieldType overview, + ContestTeamTemplateFieldType poster, + ContestTeamTemplateFieldType images +) { + public static TeamTemplateResponse from(final ContestTeamTemplate template) { + return new TeamTemplateResponse( + template.getContest().getId(), + template.getDivision(), + template.getProjectName(), + template.getTeamName(), + template.getLeader(), + template.getTeamMembers(), + template.getProfessor(), + template.getGithubPath(), + template.getYoutubePath(), + template.getProductionPath(), + template.getOverview(), + template.getPoster(), + template.getImages() + ); + } +} From d8fd40527e3181bacf0d1cffe9033ca83d45a257 Mon Sep 17 00:00:00 2001 From: JJimini Date: Sat, 31 Jan 2026 22:41:19 +0900 Subject: [PATCH 07/24] =?UTF-8?q?chore=20:=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- .../com/opus/opus/docs/asciidoc/contest.adoc | 126 ++++++++ .../com/opus/opus/docs/asciidoc/member.adoc | 60 +++- .../com/opus/opus/docs/asciidoc/notice.adoc | 77 ++++- .../com/opus/opus/docs/asciidoc/opus.adoc | 14 + .../opus/opus/docs/asciidoc/team-comment.adoc | 122 +++++++ .../opus/opus/docs/asciidoc/team-member.adoc | 162 ++++++++++ .../com/opus/opus/docs/asciidoc/team.adoc | 57 ++++ .../opus/opus/global/config/WebMvcConfig.java | 19 ++ .../util/oauth/component/GoogleOauth.java | 253 +++++++++++++++ .../util/oauth/component/SocialOauth.java | 23 ++ .../util/oauth/dto/GoogleOAuthToken.java | 12 + .../global/util/oauth/dto/GoogleUser.java | 7 + .../global/util/oauth/dto/OAuthResult.java | 8 + .../util/oauth/exception/OAuthException.java | 25 ++ .../oauth/exception/OAuthExceptionType.java | 35 ++ .../api/ContestCategoryController.java | 6 +- .../contest/api/ContestController.java | 40 ++- .../contest/api/ContestTrackController.java | 8 +- .../application/ContestCommandService.java | 32 ++ .../application/ContestQueryService.java | 12 + .../convenience/ContestConvenience.java | 4 + .../dto/request/ContestRequest.java | 3 +- .../dto/request/ContestVotesLimitRequest.java | 11 + .../dto/request/VoteUpdateRequest.java | 12 + .../response/ContestVotesLimitResponse.java | 9 + .../dto/response/VotePeriodResponse.java | 9 + .../opus/modules/contest/domain/Contest.java | 14 + .../contest/domain/ContestCategory.java | 4 +- .../modules/contest/domain/ContestTrack.java | 2 +- .../exception/ContestExceptionType.java | 5 +- .../convenience/FileConvenience.java | 8 +- .../modules/file/domain/FileImageType.java | 1 + .../file/exception/FileExceptionType.java | 3 +- .../modules/member/api/MemberController.java | 22 ++ .../application/MemberCommandService.java | 155 +++++++++ .../convenience/MemberConvenience.java | 56 ++++ .../opus/modules/member/domain/Member.java | 4 +- .../member/exception/MemberExceptionType.java | 1 + .../modules/notice/api/NoticeController.java | 41 ++- .../application/NoticeCommandService.java | 27 +- .../application/NoticeQueryService.java | 21 +- .../NoticeConvenience.java | 12 +- .../notice/domain/dao/NoticeRepository.java | 9 +- .../team/api/TeamCommentController.java | 72 +++++ .../opus/modules/team/api/TeamController.java | 24 ++ .../team/api/TeamMemberController.java | 41 +++ .../team/application/TeamCommandService.java | 39 +-- .../TeamCommentCommandService.java | 71 ++++ .../application/TeamCommentQueryService.java | 52 +++ .../application/TeamMemberCommandService.java | 46 +++ .../team/application/TeamQueryService.java | 13 +- .../convenience/TeamMemberConvenience.java | 30 ++ .../dto/request/TeamCommentCreateRequest.java | 9 + .../dto/request/TeamCommentUpdateRequest.java | 10 + .../dto/request/TeamMemberCreateRequest.java | 20 ++ .../dto/response/TeamCommentResponse.java | 11 + .../opus/modules/team/domain/TeamComment.java | 7 + .../opus/modules/team/domain/TeamMember.java | 3 +- .../domain/dao/TeamCommentRepository.java | 11 + .../team/domain/dao/TeamMemberRepository.java | 12 + .../team/exception/TeamCommentException.java | 26 ++ .../exception/TeamCommentExceptionType.java | 30 ++ .../team/exception/TeamMemberException.java | 25 ++ .../exception/TeamMemberExceptionType.java | 28 ++ src/main/resources/application.yml | 1 - src/main/resources/schema.sql | 2 +- .../opus/contest/ContestCategoryFixture.java | 12 + .../com/opus/opus/contest/ContestFixture.java | 21 ++ .../ContestCommandServiceTest.java | 128 ++++++++ .../application/ContestQueryServiceTest.java | 77 +++++ .../com/opus/opus/helper/IntegrationTest.java | 8 + .../com/opus/opus/member/MemberFixture.java | 12 +- .../application/MemberCommandServiceTest.java | 220 ++++++++++++- .../com/opus/opus/notice/NoticeFixture.java | 15 +- .../application/NoticeCommandServiceTest.java | 78 ++++- .../application/NoticeQueryServiceTest.java | 54 +++- .../com/opus/opus/restdocs/RestDocsTest.java | 50 ++- .../restdocs/docs/ContestApiDocsTest.java | 211 ++++++++++++ .../opus/restdocs/docs/MemberApiDocsTest.java | 79 +++++ .../opus/restdocs/docs/NoticeApiDocsTest.java | 141 +++++++- .../opus/restdocs/docs/TeamApiDocsTest.java | 113 +++++++ .../restdocs/docs/TeamCommentApiDocsTest.java | 225 +++++++++++++ .../restdocs/docs/TeamMemberApiDocsTest.java | 305 ++++++++++++++++++ .../java/com/opus/opus/team/FileFixture.java | 19 ++ .../opus/opus/team/TeamCommentFixture.java | 15 + .../java/com/opus/opus/team/TeamFixture.java | 23 ++ .../application/TeamCommandServiceTest.java | 123 +++++++ .../TeamCommentCommandServiceTest.java | 174 ++++++++++ .../TeamCommentQueryServiceTest.java | 109 +++++++ .../TeamMemberCommandServiceTest.java | 130 ++++++++ .../application/TeamQueryServiceTest.java | 100 ++++++ 92 files changed, 4463 insertions(+), 98 deletions(-) create mode 100644 src/main/java/com/opus/opus/docs/asciidoc/contest.adoc create mode 100644 src/main/java/com/opus/opus/docs/asciidoc/team-comment.adoc create mode 100644 src/main/java/com/opus/opus/docs/asciidoc/team-member.adoc create mode 100644 src/main/java/com/opus/opus/docs/asciidoc/team.adoc create mode 100644 src/main/java/com/opus/opus/global/config/WebMvcConfig.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/component/GoogleOauth.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/component/SocialOauth.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/dto/GoogleOAuthToken.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/dto/GoogleUser.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/dto/OAuthResult.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/exception/OAuthException.java create mode 100644 src/main/java/com/opus/opus/global/util/oauth/exception/OAuthExceptionType.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestVotesLimitRequest.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/request/VoteUpdateRequest.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestVotesLimitResponse.java create mode 100644 src/main/java/com/opus/opus/modules/contest/application/dto/response/VotePeriodResponse.java rename src/main/java/com/opus/opus/modules/notice/application/{dto => convenience}/NoticeConvenience.java (53%) create mode 100644 src/main/java/com/opus/opus/modules/team/api/TeamCommentController.java create mode 100644 src/main/java/com/opus/opus/modules/team/api/TeamMemberController.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/TeamCommentCommandService.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/TeamCommentQueryService.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/TeamMemberCommandService.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/convenience/TeamMemberConvenience.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentCreateRequest.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentUpdateRequest.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/request/TeamMemberCreateRequest.java create mode 100644 src/main/java/com/opus/opus/modules/team/application/dto/response/TeamCommentResponse.java create mode 100644 src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java create mode 100644 src/main/java/com/opus/opus/modules/team/domain/dao/TeamMemberRepository.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamCommentException.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamCommentExceptionType.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamMemberException.java create mode 100644 src/main/java/com/opus/opus/modules/team/exception/TeamMemberExceptionType.java create mode 100644 src/test/java/com/opus/opus/contest/ContestCategoryFixture.java create mode 100644 src/test/java/com/opus/opus/contest/ContestFixture.java create mode 100644 src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java create mode 100644 src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java create mode 100644 src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java create mode 100644 src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java create mode 100644 src/test/java/com/opus/opus/restdocs/docs/TeamCommentApiDocsTest.java create mode 100644 src/test/java/com/opus/opus/restdocs/docs/TeamMemberApiDocsTest.java create mode 100644 src/test/java/com/opus/opus/team/FileFixture.java create mode 100644 src/test/java/com/opus/opus/team/TeamCommentFixture.java create mode 100644 src/test/java/com/opus/opus/team/TeamFixture.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamCommentCommandServiceTest.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamCommentQueryServiceTest.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamMemberCommandServiceTest.java create mode 100644 src/test/java/com/opus/opus/team/application/TeamQueryServiceTest.java diff --git a/README.md b/README.md index a22b2a0e..09cb050d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- OPUS Service Overview(추가 예정) + Image

🎓 OPUS (SW프로젝트관리시스템)

@@ -13,7 +13,8 @@

🌐 운영 서비스 이동 | 🧩 Production Repo 이동 | - 📄 Spring Rest Docs(예정) | + 📄 Spring Rest Docs | + 🔨 개발 서버 | 📦 MVP Backend

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 00000000..3e624814 --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc @@ -0,0 +1,126 @@ +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`: 투표 기간 조회 + +.HTTP Request +include::{snippets}/get-vote-period/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-vote-period/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-vote-period/path-parameters.adoc[] + +.Response Fields +include::{snippets}/get-vote-period/response-fields.adoc[] + + +=== `PUT`: 투표 기간 수정 + +.HTTP Request +include::{snippets}/update-vote-period/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-vote-period/http-response.adoc[] + +.Path Parameters +include::{snippets}/update-vote-period/path-parameters.adoc[] + +.Request Headers +include::{snippets}/update-vote-period/request-headers.adoc[] + +.Request Fields +include::{snippets}/update-vote-period/request-fields.adoc[] + + +== 최대 투표 개수 관리 + +=== `PATCH`: 최대 투표 개수 설정 + +.Path Parameters +include::{snippets}/update-max-votes-limit/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/update-max-votes-limit/request-headers.adoc[] + +.HTTP Request +include::{snippets}/update-max-votes-limit/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-max-votes-limit/http-response.adoc[] + +.Request Fields +include::{snippets}/update-max-votes-limit/request-fields.adoc[] + +==== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 + +[%collapsible] + +==== + +include::{snippets}/update-max-votes-limit-fail-not-found/http-request.adoc[] + +include::{snippets}/update-max-votes-limit-fail-not-found/http-response.adoc[] + +==== + +.❌ Case 2: 투표 진행 중 + +[%collapsible] + +==== + +include::{snippets}/update-max-votes-limit-fail-voting-period/http-request.adoc[] + +include::{snippets}/update-max-votes-limit-fail-voting-period/http-response.adoc[] + +==== + +=== `GET`: 최대 투표 개수 조회 + +.Path Parameters +include::{snippets}/get-max-votes-limit/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/get-max-votes-limit/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-max-votes-limit/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-max-votes-limit/http-response.adoc[] + +.Response Body's Fields +include::{snippets}/get-max-votes-limit/response-fields.adoc[] + +==== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 + +[%collapsible] + +==== + +include::{snippets}/get-max-votes-limit-fail-not-found/http-request.adoc[] + +include::{snippets}/get-max-votes-limit-fail-not-found/http-response.adoc[] + +==== + diff --git a/src/main/java/com/opus/opus/docs/asciidoc/member.adoc b/src/main/java/com/opus/opus/docs/asciidoc/member.adoc index fabe8a65..f4a474a8 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/member.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/member.adoc @@ -12,7 +12,7 @@ endif::[] == API 목록 -link:../opus.html[API 목록으로 돌아가기] +link:./opus.html[API 목록으로 돌아가기] == `POST`: 회원가입 이메일 인증 @@ -37,6 +37,8 @@ include::{snippets}/signup-auth-fail/http-request.adoc[] include::{snippets}/signup-auth-fail/http-response.adoc[] +include::{snippets}/signup-auth-fail/request-fields.adoc[] + ==== == `PATCH`: 회원가입 이메일 인증코드 확인 @@ -62,6 +64,8 @@ include::{snippets}/signup-auth-confirm-fail/http-request.adoc[] include::{snippets}/signup-auth-confirm-fail/http-response.adoc[] +include::{snippets}/signup-auth-confirm-fail/request-fields.adoc[] + ==== .❌ Case 2: 만료된 인증코드 @@ -74,6 +78,8 @@ include::{snippets}/signup-auth-confirm-fail2/http-request.adoc[] include::{snippets}/signup-auth-confirm-fail2/http-response.adoc[] +include::{snippets}/signup-auth-confirm-fail2/request-fields.adoc[] + ==== == `POST`: 회원가입 @@ -152,3 +158,55 @@ include::{snippets}/get-password/path-parameters.adoc[] .Response Body's Fields include::{snippets}/get-password/response-fields.adoc[] + +== `GET`: Google OAuth 로그인 리다이렉트 + +NOTE: 사용자를 Google OAuth 인증 페이지로 리다이렉트합니다. CSRF 공격 방지를 위한 `state` 파라미터가 세션 ID와 함께 Redis에 저장됩니다. (TTL: 5분) + +.HTTP Request +include::{snippets}/oauth-google-redirect/http-request.adoc[] + +.HTTP Response +include::{snippets}/oauth-google-redirect/http-response.adoc[] + +== `GET`: Google OAuth 콜백 + +NOTE: Google OAuth 인증 완료 후 호출되는 콜백 엔드포인트입니다. 기존 회원이면 로그인 처리, 신규 회원이면 자동 가입 후 JWT 토큰을 발급합니다. + +.HTTP Request +include::{snippets}/oauth-google-callback/http-request.adoc[] + +.HTTP Response +include::{snippets}/oauth-google-callback/http-response.adoc[] + +.Query Parameters +include::{snippets}/oauth-google-callback/query-parameters.adoc[] + +.Response Body's Fields +include::{snippets}/oauth-google-callback/response-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: state 누락/불일치/만료 + +[%collapsible] + +==== + +include::{snippets}/oauth-google-callback-fail-state/http-request.adoc[] + +include::{snippets}/oauth-google-callback-fail-state/http-response.adoc[] + +==== + +.❌ Case 2: 사용자 권한 거부 + +[%collapsible] + +==== + +include::{snippets}/oauth-google-callback-fail-denied/http-request.adoc[] + +include::{snippets}/oauth-google-callback-fail-denied/http-response.adoc[] + +==== diff --git a/src/main/java/com/opus/opus/docs/asciidoc/notice.adoc b/src/main/java/com/opus/opus/docs/asciidoc/notice.adoc index 4bf48c7e..1808dffa 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/notice.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/notice.adoc @@ -12,10 +12,12 @@ endif::[] == API 목록 -link:../opus.html[API 목록으로 돌아가기] +link:./opus.html[API 목록으로 돌아가기] == `POST`: 전체 공지사항 생성 +NOTE: 전체 공지사항의 contestId는 null 입니다. + .HTTP Request Headers include::{snippets}/create-notice/request-headers.adoc[] @@ -80,3 +82,76 @@ include::{snippets}/get-all-notices/http-response.adoc[] .Response Body's Fields include::{snippets}/get-all-notices/response-fields.adoc[] + +== `POST`: 대회별 공지사항 생성 + +.HTTP Request Headers +include::{snippets}/create-contest-notice/request-headers.adoc[] + +.HTTP Request +include::{snippets}/create-contest-notice/http-request.adoc[] + +.Path Parameters +include::{snippets}/create-contest-notice/path-parameters.adoc[] + +.HTTP Response +include::{snippets}/create-contest-notice/http-response.adoc[] + +.Request Fields +include::{snippets}/create-contest-notice/request-fields.adoc[] + +== `PATCH`: 대회별 공지사항 수정 + +.HTTP Request Headers +include::{snippets}/update-contest-notice/request-headers.adoc[] + +.HTTP Request +include::{snippets}/update-contest-notice/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-contest-notice/http-response.adoc[] + +.Request Fields +include::{snippets}/update-contest-notice/request-fields.adoc[] + +.Path Parameters +include::{snippets}/update-contest-notice/path-parameters.adoc[] + +== `DELETE`: 대회별 공지사항 삭제 + +.HTTP Request Headers +include::{snippets}/delete-contest-notice/request-headers.adoc[] + +.HTTP Request +include::{snippets}/delete-contest-notice/http-request.adoc[] + +.HTTP Response +include::{snippets}/delete-contest-notice/http-response.adoc[] + +.Path Parameters +include::{snippets}/delete-contest-notice/path-parameters.adoc[] + +== `GET`: 대회별 공지사항 상세 조회 + +.HTTP Request +include::{snippets}/get-contest-notice/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-notice/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-contest-notice/path-parameters.adoc[] + +== `GET`: 대회별 공지사항 목록 조회 + +.HTTP Request +include::{snippets}/get-all-contest-notices/http-request.adoc[] + +.Path Parameters +include::{snippets}/get-all-contest-notices/path-parameters.adoc[] + +.HTTP Response +include::{snippets}/get-all-contest-notices/http-response.adoc[] + +.Response Body's Fields +include::{snippets}/get-all-contest-notices/response-fields.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 2a1b6c46..438881d1 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,17 @@ endif::[] == 멤버 관련 API link:./member.html[회원 API] + +== 팀 관련 API + +link:./team.html[팀 API] + +link:./team-comment.html[팀 댓글 API] + +link:./team-member.html[팀원 API] + +== 공지 관련 API +link:./notice.html[공지 API] + +== 대회 관련 API +link:./contest.html[대회 API] diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-comment.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-comment.adoc new file mode 100644 index 00000000..75f757d1 --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-comment.adoc @@ -0,0 +1,122 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += TEAM COMMENT API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:./opus.html[API 목록으로 돌아가기] + +== `POST`: 팀 댓글 등록 + +.Path Parameters +include::{snippets}/create-team-comment/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/create-team-comment/request-headers.adoc[] + +.HTTP Request +include::{snippets}/create-team-comment/http-request.adoc[] + +.HTTP Response +include::{snippets}/create-team-comment/http-response.adoc[] + +.Request Fields +include::{snippets}/create-team-comment/request-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 팀 + +[%collapsible] + +==== + +include::{snippets}/create-team-comment-fail-not-found/http-request.adoc[] + +include::{snippets}/create-team-comment-fail-not-found/http-response.adoc[] + +==== + +== `GET`: 팀 댓글 목록 조회 + +.Path Parameters +include::{snippets}/get-team-comments/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/get-team-comments/request-headers.adoc[] + +.HTTP Request +include::{snippets}/get-team-comments/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-team-comments/http-response.adoc[] + +.Response Body's Fields +include::{snippets}/get-team-comments/response-fields.adoc[] + +== `PATCH`: 팀 댓글 수정 + +.Path Parameters +include::{snippets}/update-team-comment/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/update-team-comment/request-headers.adoc[] + +.HTTP Request +include::{snippets}/update-team-comment/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-team-comment/http-response.adoc[] + +.Request Fields +include::{snippets}/update-team-comment/request-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 본인이 작성하지 않은 댓글 + +[%collapsible] + +==== + +include::{snippets}/update-team-comment-fail-not-owner/http-request.adoc[] + +include::{snippets}/update-team-comment-fail-not-owner/http-response.adoc[] + +==== + +== `DELETE`: 팀 댓글 삭제 + +.Path Parameters +include::{snippets}/delete-team-comment/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/delete-team-comment/request-headers.adoc[] + +.HTTP Request +include::{snippets}/delete-team-comment/http-request.adoc[] + +.HTTP Response +include::{snippets}/delete-team-comment/http-response.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 본인이 작성하지 않은 댓글 + +[%collapsible] + +==== + +include::{snippets}/delete-team-comment-fail-not-owner/http-request.adoc[] + +include::{snippets}/delete-team-comment-fail-not-owner/http-response.adoc[] + +==== diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team-member.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team-member.adoc new file mode 100644 index 00000000..81671860 --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team-member.adoc @@ -0,0 +1,162 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += TEAM MEMBER API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:./opus.html[API 목록으로 돌아가기] + +== `POST`: 팀원 추가 + +.Path Parameters +include::{snippets}/add-team-member/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/add-team-member/request-headers.adoc[] + +.HTTP Request +include::{snippets}/add-team-member/http-request.adoc[] + +.HTTP Response +include::{snippets}/add-team-member/http-response.adoc[] + +.Request Fields +include::{snippets}/add-team-member/request-fields.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 팀원명이 비어있음 + +[%collapsible] + +==== + +include::{snippets}/add-team-member-fail-empty-name/http-request.adoc[] + +include::{snippets}/add-team-member-fail-empty-name/http-response.adoc[] + +include::{snippets}/add-team-member-fail-empty-name/request-fields.adoc[] + +==== + +.❌ Case 2: 팀원학번이 비어있음 + +[%collapsible] + +==== + +include::{snippets}/add-team-member-fail-empty-student-id/http-request.adoc[] + +include::{snippets}/add-team-member-fail-empty-student-id/http-response.adoc[] + +include::{snippets}/add-team-member-fail-empty-student-id/request-fields.adoc[] + +==== + +.❌ Case 3: 존재하지 않는 팀 ID + +[%collapsible] + +==== + +include::{snippets}/add-team-member-fail-team-not-found/http-request.adoc[] + +include::{snippets}/add-team-member-fail-team-not-found/http-response.adoc[] + +include::{snippets}/add-team-member-fail-team-not-found/request-fields.adoc[] + +==== + +.❌ Case 4: 팀원명과 팀원학번이 맞지 않음 + +[%collapsible] + +==== + +include::{snippets}/add-team-member-fail-mismatch/http-request.adoc[] + +include::{snippets}/add-team-member-fail-mismatch/http-response.adoc[] + +include::{snippets}/add-team-member-fail-mismatch/request-fields.adoc[] + +==== + +.❌ Case 5: 동일한 참가자명 + 학번이 해당 팀에 이미 존재 + +[%collapsible] + +==== + +include::{snippets}/add-team-member-fail-already-exists/http-request.adoc[] + +include::{snippets}/add-team-member-fail-already-exists/http-response.adoc[] + +include::{snippets}/add-team-member-fail-already-exists/request-fields.adoc[] + +==== + +== `DELETE`: 팀원 삭제 + +.Path Parameters +include::{snippets}/delete-team-member/path-parameters.adoc[] + +.HTTP Request Headers +include::{snippets}/delete-team-member/request-headers.adoc[] + +.HTTP Request +include::{snippets}/delete-team-member/http-request.adoc[] + +.HTTP Response +include::{snippets}/delete-team-member/http-response.adoc[] + +=== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 팀 ID + +[%collapsible] + +==== + +include::{snippets}/delete-team-member-fail-team-not-found/http-request.adoc[] + +include::{snippets}/delete-team-member-fail-team-not-found/http-response.adoc[] + +include::{snippets}/delete-team-member-fail-team-not-found/path-parameters.adoc[] + +==== + +.❌ Case 2: 존재하지 않는 멤버 ID + +[%collapsible] + +==== + +include::{snippets}/delete-team-member-fail-member-not-found/http-request.adoc[] + +include::{snippets}/delete-team-member-fail-member-not-found/http-response.adoc[] + +include::{snippets}/delete-team-member-fail-member-not-found/path-parameters.adoc[] + +==== + +.❌ Case 3: 삭제 대상 팀원이 해당 팀에 없음 + +[%collapsible] + +==== + +include::{snippets}/delete-team-member-fail-not-in-team/http-request.adoc[] + +include::{snippets}/delete-team-member-fail-not-in-team/http-response.adoc[] + +include::{snippets}/delete-team-member-fail-not-in-team/path-parameters.adoc[] + +==== diff --git a/src/main/java/com/opus/opus/docs/asciidoc/team.adoc b/src/main/java/com/opus/opus/docs/asciidoc/team.adoc new file mode 100644 index 00000000..da9246db --- /dev/null +++ b/src/main/java/com/opus/opus/docs/asciidoc/team.adoc @@ -0,0 +1,57 @@ +ifndef::snippets[] +:snippets: ./build/generated-snippets +endif::[] + += TEAM API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 3 +:sectnums: + +== API 목록 + +link:../opus.html[API 목록으로 돌아가기] + +== `GET`: 팀 포스터 이미지 조회 + +.HTTP Request +include::{snippets}/get-team-poster/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-team-poster/http-response.adoc[] + +.Path Parameters +include::{snippets}/get-team-poster/path-parameters.adoc[] + +== `POST`: 팀 포스터 이미지 등록 + +.HTTP Request +include::{snippets}/save-team-poster/http-request.adoc[] + +.HTTP Response +include::{snippets}/save-team-poster/http-response.adoc[] + +.Path Parameters +include::{snippets}/save-team-poster/path-parameters.adoc[] + +.Request Headers +include::{snippets}/save-team-poster/request-headers.adoc[] + +.Request Parts +include::{snippets}/save-team-poster/request-parts.adoc[] + +== `DELETE`: 팀 포스터 이미지 삭제 + +.HTTP Request +include::{snippets}/delete-team-poster/http-request.adoc[] + +.HTTP Response +include::{snippets}/delete-team-poster/http-response.adoc[] + +.Path Parameters +include::{snippets}/delete-team-poster/path-parameters.adoc[] + +.Request Headers +include::{snippets}/delete-team-poster/request-headers.adoc[] diff --git a/src/main/java/com/opus/opus/global/config/WebMvcConfig.java b/src/main/java/com/opus/opus/global/config/WebMvcConfig.java new file mode 100644 index 00000000..ac32ace3 --- /dev/null +++ b/src/main/java/com/opus/opus/global/config/WebMvcConfig.java @@ -0,0 +1,19 @@ +package com.opus.opus.global.config; + +import com.opus.opus.global.security.annotation.MemberArgumentResolver; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final MemberArgumentResolver memberArgumentResolver; + + @Override + public void addArgumentResolvers(List argumentResolvers) { + argumentResolvers.add(memberArgumentResolver); + } +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/component/GoogleOauth.java b/src/main/java/com/opus/opus/global/util/oauth/component/GoogleOauth.java new file mode 100644 index 00000000..4ed7247a --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/component/GoogleOauth.java @@ -0,0 +1,253 @@ +package com.opus.opus.global.util.oauth.component; + + +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.FAILED_TO_GET_ACCESS_TOKEN; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.FAILED_TO_GET_SOCIAL_USER_INFO; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.SOCIAL_LOGIN_FAILED_AUTH_CODE; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.SOCIAL_LOGIN_SERVER_ERROR; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opus.opus.global.util.AuthRedisUtil; +import com.opus.opus.global.util.oauth.dto.GoogleOAuthToken; +import com.opus.opus.global.util.oauth.dto.OAuthResult; +import com.opus.opus.global.util.oauth.exception.OAuthException; +import jakarta.servlet.http.HttpServletRequest; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.util.UriComponentsBuilder; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GoogleOauth implements SocialOauth { + + @Value("${spring.oauth2.google.url}") + private String GOOGLE_SNS_URL; + + @Value("${spring.oauth2.google.client-id}") + private String GOOGLE_SNS_CLIENT_ID; + + @Value("${spring.oauth2.google.callback-login-url}") + private String GOOGLE_SNS_CALLBACK_LOGIN_URL; + + @Value("${spring.oauth2.google.frontend-local-callback-login-url}") + private String GOOGLE_SNS_FRONTEND_LOCAL_CALLBACK_LOGIN_URL; + + @Value("${spring.oauth2.google.client-secret}") + private String GOOGLE_SNS_CLIENT_SECRET; + + @Value("${spring.oauth2.google.scope}") + private String GOOGLE_DATA_ACCESS_SCOPE; + + private static final long OAUTH_STATE_TTL = 5L; + + private final ObjectMapper objectMapper; + private final RestTemplate restTemplate; + + private final AuthRedisUtil authRedisUtil; + + @Override + public String getOauthRedirectURL() { + final String callbackUrl = determineCallbackUrl(); + + final HttpServletRequest request = getCurrentHttpRequest(); + final String sessionId = request.getSession().getId(); + final String state = UUID.randomUUID().toString(); + final String stateKey = createOAuthStateKey(sessionId, state); + authRedisUtil.set(stateKey, "valid", OAUTH_STATE_TTL, TimeUnit.MINUTES); + + return UriComponentsBuilder.fromUriString(GOOGLE_SNS_URL) + .queryParam("scope", GOOGLE_DATA_ACCESS_SCOPE) + .queryParam("response_type", "code") + .queryParam("client_id", GOOGLE_SNS_CLIENT_ID) + .queryParam("redirect_uri", callbackUrl) + .queryParam("state", state) + .build() + .toUriString(); + } + + @Override + public OAuthResult getUserInfoByCode(String code, Class userType) throws JsonProcessingException { + ResponseEntity accessTokenResponse = requestAccessToken(code); + GoogleOAuthToken oAuthToken = getAccessToken(accessTokenResponse); + ResponseEntity userInfoResponse = requestUserInfo(oAuthToken); + T userInfo = getUserInfo(userInfoResponse, userType); + + return new OAuthResult<>(userInfo, oAuthToken.accessToken(), oAuthToken.refreshToken()); + } + + public String createOAuthStateKey(final String sessionId, final String state) { + return "oauth:state:" + sessionId + ":" + state; + } + + public boolean revokeToken(final String token) { + final String GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"; + + final MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("token", token); + + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(GOOGLE_REVOKE_URL, requestEntity, String.class); + if (response.getStatusCode() == HttpStatus.OK) { + log.debug("Google 토큰 연동 해제 성공"); + return true; + } + return false; + } catch (RestClientException e) { + log.error("Google 토큰 연동 해제 실패: {}", e.getMessage()); + return false; + } + } + + public String refreshAccessToken(final String refreshToken) { + final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; + + final MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", GOOGLE_SNS_CLIENT_ID); + params.add("client_secret", GOOGLE_SNS_CLIENT_SECRET); + params.add("refresh_token", refreshToken); + params.add("grant_type", "refresh_token"); + + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + try { + ResponseEntity response = restTemplate.postForEntity(GOOGLE_TOKEN_URL, requestEntity, String.class); + GoogleOAuthToken newToken = objectMapper.readValue(response.getBody(), GoogleOAuthToken.class); + return newToken.accessToken(); + } catch (Exception e) { + log.error("Google Access Token 갱신 실패: {}", e.getMessage()); + return null; + } + } + + private String determineCallbackUrl() { + try { + final HttpServletRequest request = getCurrentHttpRequest(); + final String origin = request.getHeader("Origin"); + log.debug("감지된 Origin 헤더: {}", origin); + + if (origin != null && origin.contains("localhost:5173")) { + return GOOGLE_SNS_FRONTEND_LOCAL_CALLBACK_LOGIN_URL; + } + + return GOOGLE_SNS_CALLBACK_LOGIN_URL; + + } catch (Exception e) { + log.error("콜백 URL 결정 중 오류 발생", e); + return GOOGLE_SNS_CALLBACK_LOGIN_URL; + } + } + + private HttpServletRequest getCurrentHttpRequest() { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + log.error("OAuth 인증 요청이 HTTP 요청 컨텍스트 외부에서 호출됨"); + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + return attributes.getRequest(); + } + + private ResponseEntity requestAccessToken(String code) { + final String GOOGLE_TOKEN_REQUEST_URL = "https://oauth2.googleapis.com/token"; + + final String callbackUrl = determineCallbackUrl(); + + final MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("code", code); + params.add("client_id", GOOGLE_SNS_CLIENT_ID); + params.add("client_secret", GOOGLE_SNS_CLIENT_SECRET); + params.add("redirect_uri", callbackUrl); + params.add("grant_type", "authorization_code"); + + final HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + final HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + try { + ResponseEntity responseEntity = + restTemplate.postForEntity( + GOOGLE_TOKEN_REQUEST_URL, requestEntity, String.class); + + if (responseEntity.getStatusCode() == HttpStatus.OK) { + return responseEntity; + } else { + log.error("Google Access Token Request Failed with status: {}", responseEntity.getStatusCode()); + throw new OAuthException(SOCIAL_LOGIN_FAILED_AUTH_CODE); + } + } catch (RestClientException e) { + log.error("Google Access Token Request Server Error: {}", e.getMessage()); + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + } + + private GoogleOAuthToken getAccessToken(ResponseEntity response) { + try { + GoogleOAuthToken oAuthToken = + objectMapper.readValue(response.getBody(), GoogleOAuthToken.class); + if (oAuthToken == null || oAuthToken.accessToken() == null) { + throw new OAuthException(FAILED_TO_GET_ACCESS_TOKEN); + } + return oAuthToken; + } catch (JsonProcessingException e) { + log.error("Failed to parse Google OAuth Token: {}", e.getMessage()); + throw new OAuthException(FAILED_TO_GET_ACCESS_TOKEN); + } + } + + private ResponseEntity requestUserInfo(GoogleOAuthToken oAuthToken) { + final String GOOGLE_USERINFO_REQUEST_URL = "https://www.googleapis.com/oauth2/v1/userinfo"; + + final HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + oAuthToken.accessToken()); + headers.add("Accept", MediaType.APPLICATION_JSON_VALUE); + + final HttpEntity> request = new HttpEntity<>(headers); + try { + return restTemplate.exchange( + GOOGLE_USERINFO_REQUEST_URL, HttpMethod.GET, request, String.class); + } catch (RestClientException e) { + log.error("Google User Info Request Server Error: {}", e.getMessage()); + throw new OAuthException(FAILED_TO_GET_SOCIAL_USER_INFO); + } + } + + private T getUserInfo(ResponseEntity userInfoRes, Class userType) { + try { + final T googleUser = objectMapper.readValue(userInfoRes.getBody(), userType); + if (googleUser == null) { + throw new OAuthException(FAILED_TO_GET_SOCIAL_USER_INFO); + } + return googleUser; + } catch (JsonProcessingException e) { + log.error("Failed to parse Google User Info: {}", e.getMessage()); + throw new OAuthException(FAILED_TO_GET_SOCIAL_USER_INFO); + } + } +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/component/SocialOauth.java b/src/main/java/com/opus/opus/global/util/oauth/component/SocialOauth.java new file mode 100644 index 00000000..1e65af41 --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/component/SocialOauth.java @@ -0,0 +1,23 @@ +package com.opus.opus.global.util.oauth.component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.opus.opus.global.util.oauth.dto.OAuthResult; + +public interface SocialOauth { + + /** + * 각 소셜 로그인 페이지로 리다이렉트할 URL을 빌드합니다. + * 사용자로부터 로그인 요청을 받아 소셜 로그인 서버 인증용 코드를 요청합니다. + */ + String getOauthRedirectURL(); + + /** + * 인가 코드를 사용하여 사용자 정보와 토큰을 가져옵니다. + * 내부적으로 액세스 토큰 요청 -> 사용자 정보 요청 과정을 처리합니다. + * @param code 인가 코드 + * @param userType 반환할 사용자 객체 클래스 (예: GoogleUser.class) + * @return 사용자 정보와 토큰을 담은 OAuthResult + * @throws JsonProcessingException JSON 파싱 실패 시 발생 + */ + OAuthResult getUserInfoByCode(String code, Class userType) throws JsonProcessingException; +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleOAuthToken.java b/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleOAuthToken.java new file mode 100644 index 00000000..f5643323 --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleOAuthToken.java @@ -0,0 +1,12 @@ +package com.opus.opus.global.util.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleOAuthToken( + @JsonProperty("access_token") + String accessToken, + + @JsonProperty("refresh_token") + String refreshToken +) { +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleUser.java b/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleUser.java new file mode 100644 index 00000000..370e2c6d --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/dto/GoogleUser.java @@ -0,0 +1,7 @@ +package com.opus.opus.global.util.oauth.dto; + +public record GoogleUser( + String email, + String name +) { +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/dto/OAuthResult.java b/src/main/java/com/opus/opus/global/util/oauth/dto/OAuthResult.java new file mode 100644 index 00000000..e5a5bd59 --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/dto/OAuthResult.java @@ -0,0 +1,8 @@ +package com.opus.opus.global.util.oauth.dto; + +public record OAuthResult( + T userInfo, + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthException.java b/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthException.java new file mode 100644 index 00000000..2e83a1f0 --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthException.java @@ -0,0 +1,25 @@ +package com.opus.opus.global.util.oauth.exception; + + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class OAuthException extends BaseException { + + private final OAuthExceptionType exceptionType; + + public OAuthException(final OAuthExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + public OAuthException(final OAuthExceptionType exceptionType, final String message) { + super(message); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthExceptionType.java b/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthExceptionType.java new file mode 100644 index 00000000..165d7e05 --- /dev/null +++ b/src/main/java/com/opus/opus/global/util/oauth/exception/OAuthExceptionType.java @@ -0,0 +1,35 @@ +package com.opus.opus.global.util.oauth.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum OAuthExceptionType implements BaseExceptionType { + + SOCIAL_LOGIN_FAILED_AUTH_CODE(HttpStatus.BAD_REQUEST, "소셜 로그인 인증 코드가 유효하지 않습니다."), + SOCIAL_LOGIN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 서버와의 통신에 실패했습니다."), + FAILED_TO_GET_ACCESS_TOKEN(HttpStatus.INTERNAL_SERVER_ERROR, "액세스 토큰을 가져오는데 실패했습니다."), + FAILED_TO_GET_SOCIAL_USER_INFO(HttpStatus.INTERNAL_SERVER_ERROR, "소셜 로그인 사용자 정보를 가져오는데 실패했습니다."), + INVALID_OAUTH_TOKEN_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 OAuth 토큰 타입입니다."), + OAUTH_AUTHORIZATION_FAILED(HttpStatus.UNAUTHORIZED, "소셜 로그인 인증에 실패했습니다."), + USER_DENIED_AUTHORIZATION(HttpStatus.BAD_REQUEST, "사용자가 권한 요청을 거부했습니다."), + UNSUPPORTED_SOCIAL_LOGIN_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 소셜 로그인 타입입니다."), + ; + + private final HttpStatus httpStatus; + private final String errorMessage; + + OAuthExceptionType(final HttpStatus httpStatus, final String errorMessage) { + this.httpStatus = httpStatus; + this.errorMessage = errorMessage; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return errorMessage; + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/api/ContestCategoryController.java b/src/main/java/com/opus/opus/modules/contest/api/ContestCategoryController.java index deb94ed1..9d9f136f 100644 --- a/src/main/java/com/opus/opus/modules/contest/api/ContestCategoryController.java +++ b/src/main/java/com/opus/opus/modules/contest/api/ContestCategoryController.java @@ -29,24 +29,24 @@ public class ContestCategoryController { private final ContestCategoryCommandService contestCategoryCommandService; private final ContestCategoryQueryService contestCategoryQueryService; - @PostMapping @Secured("ROLE_관리자") + @PostMapping public ResponseEntity createContestCategory( @Valid @RequestBody final ContestCategoryRequest request) { contestCategoryCommandService.createCategory(request); return ResponseEntity.status(HttpStatus.CREATED).build(); } - @PatchMapping("/{categoryId}") @Secured("ROLE_관리자") + @PatchMapping("/{categoryId}") public ResponseEntity updateContestCategory(@Valid @RequestBody final ContestCategoryRequest request, @PathVariable final Long categoryId) { contestCategoryCommandService.updateCategory(categoryId, request); return ResponseEntity.noContent().build(); } - @DeleteMapping("/{categoryId}") @Secured("ROLE_관리자") + @DeleteMapping("/{categoryId}") public ResponseEntity deleteContestCategory(@PathVariable final Long categoryId) { contestCategoryCommandService.deleteCategory(categoryId); return ResponseEntity.noContent().build(); 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 5e862692..c96f460e 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 @@ -11,6 +11,10 @@ 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.TeamTemplateResponse; +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.ContestVotesLimitResponse; +import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.team.application.dto.ImageResponse; import jakarta.validation.Valid; import java.util.List; @@ -73,30 +77,30 @@ public ResponseEntity> getAllContests() { return ResponseEntity.ok(responses); } - @PostMapping @Secured("ROLE_관리자") + @PostMapping public ResponseEntity createContest(@Valid @RequestBody final ContestRequest request) { ContestResponse response = contestCommandService.createContest(request); return ResponseEntity.ok(response); } - @PatchMapping("/{contestId}") @Secured("ROLE_관리자") + @PatchMapping("/{contestId}") public ResponseEntity updateContest(@PathVariable final Long contestId, @Valid @RequestBody final ContestRequest request) { contestCommandService.updateContest(contestId, request); return ResponseEntity.noContent().build(); } - @DeleteMapping("/{contestId}") @Secured("ROLE_관리자") + @DeleteMapping("/{contestId}") public ResponseEntity deleteContest(@PathVariable final Long contestId) { contestCommandService.deleteContest(contestId); return ResponseEntity.noContent().build(); } - @PatchMapping("/{contestId}/current") @Secured("ROLE_관리자") + @PatchMapping("/{contestId}/current") public ResponseEntity toggleCurrent(@PathVariable final Long contestId, @Valid @RequestBody final ContestCurrentToggleRequest request) { ContestCurrentToggleResponse response = contestCommandService.toggleCurrent(contestId, request.isCurrent()); @@ -122,4 +126,32 @@ public ResponseEntity updateTeamDetailTemplate(@PathVariable final Long co contestTeamTemplateCommandService.updateTeamTemplate(contestId, request); return ResponseEntity.noContent().build(); } + + @GetMapping("/{contestId}/vote") + public ResponseEntity getVotePeriod(@PathVariable final Long contestId) { + return ResponseEntity.ok(contestQueryService.getVotePeriod(contestId)); + } + + @Secured("ROLE_관리자") + @PutMapping("/{contestId}/vote") + public ResponseEntity updateVotePeriod(@PathVariable final Long contestId, + @Valid @RequestBody final VoteUpdateRequest voteRequest) { + contestCommandService.updateVotePeriod(contestId, voteRequest); + return ResponseEntity.noContent().build(); + } + + @Secured("ROLE_관리자") + @PatchMapping("/{contestId}/votes") + public ResponseEntity updateMaxVotesLimit(@PathVariable final Long contestId, + @Valid @RequestBody final ContestVotesLimitRequest request) { + contestCommandService.updateMaxVotesLimit(contestId, request.maxVotesLimit()); + return ResponseEntity.noContent().build(); + } + + @Secured("ROLE_관리자") + @GetMapping("/{contestId}/votes") + public ResponseEntity getMaxVotesLimit(@PathVariable final Long contestId) { + final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contestId); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/api/ContestTrackController.java b/src/main/java/com/opus/opus/modules/contest/api/ContestTrackController.java index 1ba5775a..370dbf79 100644 --- a/src/main/java/com/opus/opus/modules/contest/api/ContestTrackController.java +++ b/src/main/java/com/opus/opus/modules/contest/api/ContestTrackController.java @@ -24,22 +24,22 @@ @Validated @RestController @RequiredArgsConstructor -@RequestMapping("/contest/{contestId}/tracks") +@RequestMapping("/contests/{contestId}/tracks") public class ContestTrackController { private final ContestTrackCommandService contestTrackCommandService; private final ContestTrackQueryService contestTrackQueryService; - @PostMapping @Secured("ROLE_관리자") + @PostMapping public ResponseEntity createContestTrack(@Valid @RequestBody final ContestTrackRequest request, @PathVariable final Long contestId) { contestTrackCommandService.createTrack(contestId, request); return ResponseEntity.status(HttpStatus.CREATED).build(); } - @PatchMapping("/{trackId}") @Secured("ROLE_관리자") + @PatchMapping("/{trackId}") public ResponseEntity updateContestTrack(@Valid @RequestBody final ContestTrackRequest request, @PathVariable final Long contestId, @PathVariable final Long trackId) { @@ -47,8 +47,8 @@ public ResponseEntity updateContestTrack(@Valid @RequestBody final Contest return ResponseEntity.noContent().build(); } - @DeleteMapping("/{trackId}") @Secured("ROLE_관리자") + @DeleteMapping("/{trackId}") public ResponseEntity deleteContestTrack(@PathVariable final Long contestId, @PathVariable final Long trackId) { contestTrackCommandService.deleteTrack(contestId, trackId); 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 8c1a8aad..ce6ca42b 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,8 +1,10 @@ package com.opus.opus.modules.contest.application; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.*; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED; import static com.opus.opus.modules.file.domain.FileImageType.BANNER; import static com.opus.opus.modules.file.domain.ReferenceDomainType.CONTEST; @@ -13,12 +15,15 @@ import com.opus.opus.modules.contest.application.convenience.ContestConvenience; import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; import com.opus.opus.modules.contest.application.dto.request.ContestRequest; +import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse; import com.opus.opus.modules.contest.application.dto.response.ContestResponse; +import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.ContestCategory; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.contest.exception.ContestExceptionType; import com.opus.opus.modules.file.domain.File; import com.opus.opus.modules.file.domain.dao.FileRepository; import com.opus.opus.modules.file.exception.FileException; @@ -106,6 +111,33 @@ public ContestCurrentToggleResponse toggleCurrent(final Long contestId, final Bo return ContestCurrentToggleResponse.of(contest.getId(), isCurrent); } + public void updateVotePeriod(final Long contestId, final VoteUpdateRequest voteRequest) { + final Contest contest = contestConvenience.getValidateExistContest(contestId); + checkVoteRange(voteRequest); + contest.updateVotePeriod(voteRequest.voteStartAt(), voteRequest.voteEndAt()); + } + + private void checkVoteRange(final VoteUpdateRequest voteRequest) { + final int compare = voteRequest.voteStartAt().compareTo(voteRequest.voteEndAt()); + if (compare > 0) { + throw new ContestException(VOTE_END_PRECEDE_VOTE_START); + } + } + + public void updateMaxVotesLimit(final Long contestId, final Integer maxVotesLimit) { + final Contest contest = contestConvenience.getValidateExistContest(contestId); + + validateNotInVotingPeriod(contest); + + contest.updateMaxVotesLimit(maxVotesLimit); + } + + private void validateNotInVotingPeriod(final Contest contest) { + if (contest.isVotingPeriod()) { + throw new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD); + } + } + private void checkWebpConverted(File existingFile) { if (!existingFile.getIsWebpConverted()) { throw new FileException(NOT_WEBP_CONVERTED); diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestQueryService.java index 941f0f8f..2e9aee64 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 @@ -10,6 +10,8 @@ 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.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.dao.ContestRepository; @@ -72,6 +74,16 @@ public List getAllContests() { .toList(); } + public VotePeriodResponse getVotePeriod(final Long contestId) { + final Contest contest = contestConvenience.getValidateExistContest(contestId); + return new VotePeriodResponse(contest.getVoteStartAt(), contest.getVoteEndAt()); + } + + public ContestVotesLimitResponse getMaxVotesLimit(final Long contestId) { + final Contest contest = contestConvenience.getValidateExistContest(contestId); + return ContestVotesLimitResponse.from(contest.getMaxVotesLimit()); + } + 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 d6322501..627bbe01 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 @@ -24,6 +24,10 @@ public Contest getValidateExistContest(final Long contestId) { return contestRepository.findById(contestId).orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST)); } + public void validateExistContest(final Long contestId) { + contestRepository.findById(contestId).orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST)); + } + public void validateAllContestsDeletedInCategory(final Long categoryId) { if (contestRepository.existsByCategoryId(categoryId)) { throw new ContestException(CATEGORY_HAS_CONTEST); diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestRequest.java index fb5f789b..85c2bb20 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestRequest.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestRequest.java @@ -1,11 +1,12 @@ package com.opus.opus.modules.contest.application.dto.request; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; public record ContestRequest( @NotBlank(message = "대회명은 비어 있을 수 없습니다.") String contestName, - @NotBlank(message = "카테고리ID는 비어 있을 수 없습니다.") + @NotNull(message = "카테고리ID는 비어 있을 수 없습니다.") Long categoryId ) { } diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestVotesLimitRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestVotesLimitRequest.java new file mode 100644 index 00000000..7321cb0c --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestVotesLimitRequest.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.contest.application.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record ContestVotesLimitRequest( + @NotNull(message = "최대 투표 개수는 필수입니다.") + @Min(value = 0, message = "최대 투표 개수는 0 이상이어야 합니다.") + Integer maxVotesLimit +) { +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/VoteUpdateRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/VoteUpdateRequest.java new file mode 100644 index 00000000..617f0be8 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/VoteUpdateRequest.java @@ -0,0 +1,12 @@ +package com.opus.opus.modules.contest.application.dto.request; + +import jakarta.validation.constraints.NotNull; +import java.time.LocalDateTime; + +public record VoteUpdateRequest( + @NotNull(message = "투표 시작 시각을 정해야 합니다") + LocalDateTime voteStartAt, + @NotNull(message = "투표 종료 시각을 정해야 합니다") + LocalDateTime voteEndAt +) { +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestVotesLimitResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestVotesLimitResponse.java new file mode 100644 index 00000000..cd0d2400 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestVotesLimitResponse.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.contest.application.dto.response; + +public record ContestVotesLimitResponse( + Integer maxVotesLimit +) { + public static ContestVotesLimitResponse from(final Integer maxVotesLimit) { + return new ContestVotesLimitResponse(maxVotesLimit); + } +} diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/VotePeriodResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/VotePeriodResponse.java new file mode 100644 index 00000000..d7b16bcc --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/VotePeriodResponse.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.contest.application.dto.response; + +import java.time.LocalDateTime; + +public record VotePeriodResponse( + LocalDateTime voteStartAt, + LocalDateTime voteEndAt +) { +} diff --git a/src/main/java/com/opus/opus/modules/contest/domain/Contest.java b/src/main/java/com/opus/opus/modules/contest/domain/Contest.java index 505001be..877587c6 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/Contest.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/Contest.java @@ -71,4 +71,18 @@ public void updateContest(final Long categoryId, final String contestName) { this.categoryId = categoryId; this.contestName = contestName; } + + public void updateMaxVotesLimit(final Integer maxVotesLimit) { + this.maxVotesLimit = maxVotesLimit; + } + + public boolean isVotingPeriod() { + final LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(voteStartAt) && !now.isAfter(voteEndAt); + } + + public void updateVotePeriod(final LocalDateTime voteStartAt, final LocalDateTime voteEndAt) { + this.voteStartAt = voteStartAt; + this.voteEndAt = voteEndAt; + } } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestCategory.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestCategory.java index 67c3624f..65ac504a 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestCategory.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestCategory.java @@ -17,7 +17,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("is_deleted = false") -@SQLDelete(sql = "UPDATE contest SET is_deleted = true where id = ?") +@SQLDelete(sql = "UPDATE contest_category SET is_deleted = true WHERE id = ?") public class ContestCategory extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,7 +28,7 @@ public class ContestCategory extends BaseEntity { @Column(nullable = false) private Boolean isDeleted; - + @Builder private ContestCategory(final String categoryName) { this.categoryName = categoryName; diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTrack.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTrack.java index 53c49e7f..89d7bf89 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTrack.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTrack.java @@ -21,7 +21,7 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("is_deleted = false") -@SQLDelete(sql = "UPDATE contest SET is_deleted = true where id = ?") +@SQLDelete(sql = "UPDATE contest_track SET is_deleted = true WHERE id = ?") public class ContestTrack extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 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 b05c7c50..3cc966f0 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 @@ -10,7 +10,10 @@ public enum ContestExceptionType implements BaseExceptionType { ALREADY_CURRENT_CONTEST(HttpStatus.BAD_REQUEST, "이미 현재 대회입니다."), ALREADY_NOT_CURRENT_CONTEST(HttpStatus.BAD_REQUEST, "이미 현재 대회가 아닙니다."), CATEGORY_HAS_CONTEST(HttpStatus.CONFLICT, "해당 카테고리에 속한 대회가 존재합니다."), - CONTEST_NAME_ALREADY_EXIST(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, "투표 진행중에는 최대 투표 개수를 변경할 수 없습니다.") + ; private final HttpStatus httpStatus; private final String errorMessage; diff --git a/src/main/java/com/opus/opus/modules/file/application/convenience/FileConvenience.java b/src/main/java/com/opus/opus/modules/file/application/convenience/FileConvenience.java index b37cf240..6722391a 100644 --- a/src/main/java/com/opus/opus/modules/file/application/convenience/FileConvenience.java +++ b/src/main/java/com/opus/opus/modules/file/application/convenience/FileConvenience.java @@ -1,8 +1,6 @@ package com.opus.opus.modules.file.application.convenience; -import static com.opus.opus.modules.file.domain.FileImageType.THUMBNAIL; -import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; -import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_EXISTS_THUMBNAIL; +import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_EXISTS_MATCHING_IMAGE_ID; import com.opus.opus.modules.file.domain.File; import com.opus.opus.modules.file.domain.FileImageType; @@ -23,7 +21,7 @@ public class FileConvenience { public File findByReferenceIdAndReferenceTypeAndImageType(final Long teamId, final ReferenceDomainType referenceType, final FileImageType imageType) { - return fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, THUMBNAIL) - .orElseThrow(() -> new FileException(NOT_EXISTS_THUMBNAIL)); + return fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, referenceType, imageType) + .orElseThrow(() -> new FileException(NOT_EXISTS_MATCHING_IMAGE_ID)); } } diff --git a/src/main/java/com/opus/opus/modules/file/domain/FileImageType.java b/src/main/java/com/opus/opus/modules/file/domain/FileImageType.java index f8d439a4..f9630a9a 100644 --- a/src/main/java/com/opus/opus/modules/file/domain/FileImageType.java +++ b/src/main/java/com/opus/opus/modules/file/domain/FileImageType.java @@ -5,5 +5,6 @@ public enum FileImageType { PREVIEW, THUMBNAIL, BANNER, + POSTER, } diff --git a/src/main/java/com/opus/opus/modules/file/exception/FileExceptionType.java b/src/main/java/com/opus/opus/modules/file/exception/FileExceptionType.java index 3cffc8a4..572a8a4e 100644 --- a/src/main/java/com/opus/opus/modules/file/exception/FileExceptionType.java +++ b/src/main/java/com/opus/opus/modules/file/exception/FileExceptionType.java @@ -5,12 +5,11 @@ public enum FileExceptionType implements BaseExceptionType { NOT_EXISTS_MATCHING_IMAGE_ID(HttpStatus.NOT_FOUND, "아이디와 일치하는 이미지가 없습니다"), - NOT_EXISTS_THUMBNAIL(HttpStatus.NOT_FOUND, "팀 썸네일이 존재하지 않습니다"), + NOT_EXISTS_MATCHING_IMAGE_ID_AND_TYPE(HttpStatus.NOT_FOUND, "아이디, 타입과 일치하는 이미지가 없습니다"), NOT_EXISTS_PREVIEW(HttpStatus.NOT_FOUND, "존재하지 않는 팀 프리뷰 ID 를 요청하였습니다"), EXCEED_PREVIEW_LIMIT(HttpStatus.BAD_REQUEST, "프리뷰 이미지는 6장 이하입니다"), NOT_EXISTS_PHYSICAL_FILE(HttpStatus.NOT_FOUND, "물리적 파일이 존재하지 않습니다"), NOT_WEBP_CONVERTED(HttpStatus.ACCEPTED, "이미지 변환중 입니다"), - NOT_EXISTS_BANNER(HttpStatus.BAD_REQUEST, "배너가 존재하지 않습니다") ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/opus/opus/modules/member/api/MemberController.java b/src/main/java/com/opus/opus/modules/member/api/MemberController.java index ae138299..5d20682f 100644 --- a/src/main/java/com/opus/opus/modules/member/api/MemberController.java +++ b/src/main/java/com/opus/opus/modules/member/api/MemberController.java @@ -13,7 +13,9 @@ import com.opus.opus.modules.member.application.dto.response.EmailFindResponse; import com.opus.opus.modules.member.application.dto.response.SignInResponse; import jakarta.validation.Valid; +import java.net.URI; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; @@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; @Validated @RestController @@ -80,4 +83,23 @@ public ResponseEntity getMyEmail(@PathVariable final String s final EmailFindResponse response = memberQueryService.getMyEmail(studentId); return ResponseEntity.ok(response); } + + @GetMapping("/oauth/google") + public ResponseEntity googleOAuthRedirect() { + final String redirectURL = memberCommandService.getGoogleOAuthRedirectURL(); + + URI uri = UriComponentsBuilder.fromUriString(redirectURL).encode().build().toUri(); + + return ResponseEntity.status(HttpStatus.FOUND).location(uri).build(); + } + + @GetMapping("/oauth/google/callback") + public ResponseEntity googleOAuthCallback( + final String code, + final String state, + final String error + ) { + final SignInResponse response = memberCommandService.getGoogleOAuthCallback(code, state, error); + return ResponseEntity.ok(response); + } } diff --git a/src/main/java/com/opus/opus/modules/member/application/MemberCommandService.java b/src/main/java/com/opus/opus/modules/member/application/MemberCommandService.java index e8444a8e..eac158a7 100644 --- a/src/main/java/com/opus/opus/modules/member/application/MemberCommandService.java +++ b/src/main/java/com/opus/opus/modules/member/application/MemberCommandService.java @@ -1,5 +1,10 @@ package com.opus.opus.modules.member.application; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.FAILED_TO_GET_SOCIAL_USER_INFO; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.OAUTH_AUTHORIZATION_FAILED; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.SOCIAL_LOGIN_FAILED_AUTH_CODE; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.SOCIAL_LOGIN_SERVER_ERROR; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.USER_DENIED_AUTHORIZATION; import static com.opus.opus.modules.member.domain.MemberRoleType.ROLE_회원; import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_CHANGE_SAME_PASSWORD; import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_MATCH_EMAIL_AUTH_CODE; @@ -7,9 +12,14 @@ import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_VERIFY_EXPIRED_EMAIL_AUTH_CODE; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_VERIFIED_EMAIL_AUTH; +import com.fasterxml.jackson.core.JsonProcessingException; import com.opus.opus.global.security.JwtProvider; import com.opus.opus.global.util.AuthRedisUtil; import com.opus.opus.global.util.MailUtil; +import com.opus.opus.global.util.oauth.component.GoogleOauth; +import com.opus.opus.global.util.oauth.dto.GoogleUser; +import com.opus.opus.global.util.oauth.dto.OAuthResult; +import com.opus.opus.global.util.oauth.exception.OAuthException; import com.opus.opus.modules.member.application.convenience.MemberConvenience; import com.opus.opus.modules.member.application.dto.request.EmailAuthConfirmRequest; import com.opus.opus.modules.member.application.dto.request.EmailAuthRequest; @@ -21,16 +31,20 @@ import com.opus.opus.modules.member.domain.MemberRoleType; import com.opus.opus.modules.member.domain.dao.MemberRepository; import com.opus.opus.modules.member.exception.MemberException; +import jakarta.servlet.http.HttpServletRequest; import java.security.SecureRandom; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; @Service @Transactional @@ -45,13 +59,17 @@ public class MemberCommandService { private final JwtProvider jwtProvider; private final MailUtil mailUtil; private final AuthRedisUtil authRedisUtil; + private final GoogleOauth googleOauth; private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private static final int AUTH_CODE_LENGTH = 10; private static final char[] AUTH_CODE_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray(); + private static final String PASSWORD_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; private static final long SIGNUP_AUTH_CODE_TTL = 5L; private static final long SIGNUP_VERIFIED_TTL = 10L; + private static final long GOOGLE_TOKEN_TTL = 3L; // 3시간 + private static final String GOOGLE_TOKEN_KEY_PREFIX = "oauth:google:token:"; private static final String SIGNUP_EMAIL_AUTH_KEY_PREFIX = "signup:email:auth:"; private static final String SIGNUP_EMAIL_VERIFIED_KEY_PREFIX = "signup:email:verified:"; @@ -140,6 +158,102 @@ public void updatePassword(final PasswordUpdateRequest request) { authRedisUtil.delete(signInVerifiedKey(email)); } + public String getGoogleOAuthRedirectURL() { + try { + return googleOauth.getOauthRedirectURL(); + } catch (Exception e) { + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + } + + public SignInResponse getGoogleOAuthCallback(final String code, final String state, final String error) { + try { + validateGoogleOAuthRequest(code, state, error); + + final OAuthResult result = googleOauth.getUserInfoByCode(code, GoogleUser.class); + final GoogleUser googleUser = result.userInfo(); + + return memberRepository.findByEmail(googleUser.email()) + .map(member -> processExistingMemberLogin(member, result)) + .orElseGet(() -> processNewMemberSignUp(googleUser, result)); + + } catch (OAuthException e) { + throw e; + } catch (JsonProcessingException e) { + throw new OAuthException(FAILED_TO_GET_SOCIAL_USER_INFO); + } catch (Exception e) { + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + } + + public void unlinkGoogleAccount(final Long memberId) { + final String key = GOOGLE_TOKEN_KEY_PREFIX + memberId; + final String storedValue = authRedisUtil.get(key); + + if (storedValue == null) { // 구글 로그인하지 않은 회원인 경우 + return; + } + + String[] tokens = storedValue.split(":"); + final String accessToken = tokens[0]; + final String refreshToken = tokens[1]; + boolean success = googleOauth.revokeToken(accessToken); + + if (!success && refreshToken != null) { + String newAccessToken = googleOauth.refreshAccessToken(refreshToken); + if (newAccessToken != null) { + googleOauth.revokeToken(newAccessToken); + } + } + + authRedisUtil.delete(key); + } + + private SignInResponse processExistingMemberLogin(final Member member, final OAuthResult oAuthResult) { + saveGoogleToken(member.getId(), oAuthResult); + + final List roles = member.getRoles().stream() + .map(MemberRoleType::toString) + .toList(); + final String token = jwtProvider.createToken(String.valueOf(member.getId()), roles, member.getName()); + + return SignInResponse.from(member, token); + } + + private SignInResponse processNewMemberSignUp(final GoogleUser googleUser, final OAuthResult oAuthResult) { + try { + final Member newMember = getRegisterNewMember(googleUser.name(), googleUser.email()); + return processExistingMemberLogin(newMember, oAuthResult); + + } catch (Exception e) { + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + } + + private Member getRegisterNewMember(final String name, final String email) { + final String randomPassword = generateRandomPassword(); + final String uniqueStudentId = "fake_" + UUID.randomUUID().toString().replace("-", "").substring(0, 10); + + return memberRepository.save(Member.builder() + .name(name) + .studentId(uniqueStudentId) + .email(email) + .password(randomPassword) + .roles(Set.of(ROLE_회원)) + .build()); + } + + private String generateRandomPassword() { + final int passwordLength = 32; + + StringBuilder password = new StringBuilder(); + for (int i = 0; i < passwordLength; i++) { + password.append(PASSWORD_POOL.charAt(SECURE_RANDOM.nextInt(PASSWORD_POOL.length()))); + } + + return passwordEncoder.encode(password.toString()); + } + private void verifyVerifiedKey(final String verifiedKey) { if (authRedisUtil.get(verifiedKey) == null) { throw new MemberException(NOT_VERIFIED_EMAIL_AUTH); @@ -220,4 +334,45 @@ private static String signInAuthKey(final String email) { private static String signInVerifiedKey(final String email) { return SIGNIN_EMAIL_VERIFIED_KEY_PREFIX + email; } + + private void validateGoogleOAuthRequest(final String code, final String state, final String error) { + validateState(state); + + if (error != null) { + throw new OAuthException(USER_DENIED_AUTHORIZATION); + } + + if (code == null || code.isBlank()) { + throw new OAuthException(SOCIAL_LOGIN_FAILED_AUTH_CODE); + } + } + + private void validateState(final String state) { + if (state == null || state.isBlank()) { + throw new OAuthException(OAUTH_AUTHORIZATION_FAILED); + } + + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + throw new OAuthException(SOCIAL_LOGIN_SERVER_ERROR); + } + + HttpServletRequest request = attributes.getRequest(); + String sessionId = request.getSession().getId(); + String stateKey = googleOauth.createOAuthStateKey(sessionId, state); + String storedState = authRedisUtil.get(stateKey); + + if (storedState == null) { + throw new OAuthException(OAUTH_AUTHORIZATION_FAILED); + } + + authRedisUtil.delete(stateKey); + } + + private void saveGoogleToken(final Long memberId, final OAuthResult oAuthResult) { + final String key = GOOGLE_TOKEN_KEY_PREFIX + memberId; + final String value = oAuthResult.accessToken() + ":" + oAuthResult.refreshToken(); + authRedisUtil.set(key, value, GOOGLE_TOKEN_TTL, TimeUnit.HOURS); + } } diff --git a/src/main/java/com/opus/opus/modules/member/application/convenience/MemberConvenience.java b/src/main/java/com/opus/opus/modules/member/application/convenience/MemberConvenience.java index 02cb10ff..5becaca9 100644 --- a/src/main/java/com/opus/opus/modules/member/application/convenience/MemberConvenience.java +++ b/src/main/java/com/opus/opus/modules/member/application/convenience/MemberConvenience.java @@ -1,14 +1,20 @@ package com.opus.opus.modules.member.application.convenience; +import static com.opus.opus.modules.member.domain.MemberRoleType.ROLE_회원; import static com.opus.opus.modules.member.exception.MemberExceptionType.ALREADY_EXIST_EMAIL; import static com.opus.opus.modules.member.exception.MemberExceptionType.ALREADY_EXIST_STUDENT_ID; +import static com.opus.opus.modules.member.exception.MemberExceptionType.MISMATCH_STUDENT_ID_AND_NAME; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_FOUND_MEMBER; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_PUSAN_UNIVERSITY_EMAIL; import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.member.domain.dao.MemberRepository; import com.opus.opus.modules.member.exception.MemberException; +import java.util.List; +import java.security.SecureRandom; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +23,11 @@ @Transactional(readOnly = true) public class MemberConvenience { + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String PASSWORD_POOL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*"; + final private MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; public Member getValidateExistMember(final Long memberId) { return memberRepository.findById(memberId) @@ -53,4 +63,50 @@ public void checkIsDuplicateStudentId(final String studentId) { throw new MemberException(ALREADY_EXIST_STUDENT_ID); } } + + public List findAllById(final List memberIds) { + return memberRepository.findAllById(memberIds); + } + + private void validateNameMatchesStudentId(final String studentId, final String name) { + memberRepository.findByStudentId(studentId) + .ifPresent(member -> { + if (!member.getName().equals(name)) { + throw new MemberException(MISMATCH_STUDENT_ID_AND_NAME); + } + }); + } + + public Member getOrCreateFakeMember(final String studentId, final String name) { + validateNameMatchesStudentId(studentId, name); + + return memberRepository.findByStudentId(studentId) + .orElseGet(() -> registerFakeMember(studentId, name)); + } + + private Member registerFakeMember(final String studentId, final String name) { + final String email = "fake_" + studentId + "@pusan.ac.kr"; + final String randomPassword = generateRandomPassword(); + + return memberRepository.save( + Member.builder() + .name(name) + .studentId(studentId) + .email(email) + .password(randomPassword) + .roles(Set.of(ROLE_회원)) + .build() + ); + } + + private String generateRandomPassword() { + final int passwordLength = 32; + + StringBuilder password = new StringBuilder(); + for (int i = 0; i < passwordLength; i++) { + password.append(PASSWORD_POOL.charAt(SECURE_RANDOM.nextInt(PASSWORD_POOL.length()))); + } + + return passwordEncoder.encode(password.toString()); + } } diff --git a/src/main/java/com/opus/opus/modules/member/domain/Member.java b/src/main/java/com/opus/opus/modules/member/domain/Member.java index 3b98f7f3..f060d2ea 100644 --- a/src/main/java/com/opus/opus/modules/member/domain/Member.java +++ b/src/main/java/com/opus/opus/modules/member/domain/Member.java @@ -1,7 +1,7 @@ package com.opus.opus.modules.member.domain; +import static jakarta.persistence.FetchType.EAGER; import static jakarta.persistence.FetchType.LAZY; - import com.opus.opus.global.base.BaseEntity; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; @@ -47,7 +47,7 @@ public class Member extends BaseEntity { @Column(nullable = false, unique = true) private String studentId; - @ElementCollection(fetch = LAZY) + @ElementCollection(fetch = EAGER) @CollectionTable(name = "member_roles", joinColumns = @JoinColumn(name = "member_id")) @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false, length = MAX_ROLE_NAME_LENGTH) diff --git a/src/main/java/com/opus/opus/modules/member/exception/MemberExceptionType.java b/src/main/java/com/opus/opus/modules/member/exception/MemberExceptionType.java index b88af297..0572e7e6 100644 --- a/src/main/java/com/opus/opus/modules/member/exception/MemberExceptionType.java +++ b/src/main/java/com/opus/opus/modules/member/exception/MemberExceptionType.java @@ -15,6 +15,7 @@ public enum MemberExceptionType implements BaseExceptionType { CANNOT_CHANGE_SAME_PASSWORD(HttpStatus.BAD_REQUEST, "동일한 비밀번호로 변경할 수 없습니다."), CANNOT_MATCH_EMAIL_AUTH_CODE(HttpStatus.BAD_REQUEST, "이메일 인증 코드가 일치하지 않습니다."), CANNOT_VERIFY_EXPIRED_EMAIL_AUTH_CODE(HttpStatus.BAD_REQUEST, "이메일 인증 코드 만료 시간이 초과되었습니다."), + MISMATCH_STUDENT_ID_AND_NAME(HttpStatus.BAD_REQUEST, "입력한 학번과 이름이 일치하지 않습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/opus/opus/modules/notice/api/NoticeController.java b/src/main/java/com/opus/opus/modules/notice/api/NoticeController.java index c11c6801..bb5e74cf 100644 --- a/src/main/java/com/opus/opus/modules/notice/api/NoticeController.java +++ b/src/main/java/com/opus/opus/modules/notice/api/NoticeController.java @@ -37,8 +37,8 @@ public ResponseEntity createNotice(@Valid @RequestBody final NoticeRequest @Secured("ROLE_관리자") @PatchMapping("/notices/{noticeId}") - public ResponseEntity updateNotice(@Valid @RequestBody final NoticeRequest request, - @PathVariable final Long noticeId) { + public ResponseEntity updateNotice(@PathVariable final Long noticeId, + @Valid @RequestBody final NoticeRequest request) { noticeCommandService.updateNotice(request, noticeId); return ResponseEntity.noContent().build(); } @@ -59,4 +59,41 @@ public ResponseEntity getNotice(@PathVariable final Long n public ResponseEntity> getAllNotices() { return ResponseEntity.ok(noticeQueryService.getAllNotices()); } + + @Secured("ROLE_관리자") + @PostMapping("/contests/{contestId}/notices") + public ResponseEntity createContestNotice(@PathVariable final Long contestId, + @Valid @RequestBody final NoticeRequest request) { + noticeCommandService.createContestNotice(contestId, request); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Secured("ROLE_관리자") + @PatchMapping("/contests/{contestId}/notices/{noticeId}") + public ResponseEntity updateContestNotice(@PathVariable final Long contestId, + @PathVariable final Long noticeId, + @Valid @RequestBody final NoticeRequest request) { + noticeCommandService.updateContestNotice(request, contestId, noticeId); + return ResponseEntity.noContent().build(); + } + + @Secured("ROLE_관리자") + @DeleteMapping("/contests/{contestId}/notices/{noticeId}") + public ResponseEntity deleteContestNotice(@PathVariable final Long contestId, + @PathVariable final Long noticeId) { + noticeCommandService.deleteContestNotice(contestId, noticeId); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/contests/{contestId}/notices/{noticeId}") + public ResponseEntity getContestNotice(@PathVariable final Long contestId, + @PathVariable final Long noticeId) { + return ResponseEntity.ok(noticeQueryService.getContestNotice(contestId, noticeId)); + } + + @GetMapping("/contests/{contestId}/notices") + public ResponseEntity> getAllContestNotices(@PathVariable final Long contestId) { + return ResponseEntity.ok(noticeQueryService.getAllContestNotices(contestId)); + } + } diff --git a/src/main/java/com/opus/opus/modules/notice/application/NoticeCommandService.java b/src/main/java/com/opus/opus/modules/notice/application/NoticeCommandService.java index efc7504c..8628a89a 100644 --- a/src/main/java/com/opus/opus/modules/notice/application/NoticeCommandService.java +++ b/src/main/java/com/opus/opus/modules/notice/application/NoticeCommandService.java @@ -1,6 +1,7 @@ package com.opus.opus.modules.notice.application; -import com.opus.opus.modules.notice.application.dto.NoticeConvenience; +import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.notice.application.convenience.NoticeConvenience; import com.opus.opus.modules.notice.application.dto.request.NoticeRequest; import com.opus.opus.modules.notice.domain.Notice; import com.opus.opus.modules.notice.domain.dao.NoticeRepository; @@ -16,6 +17,7 @@ public class NoticeCommandService { private final NoticeRepository noticeRepository; private final NoticeConvenience noticeConvenience; + private final ContestConvenience contestConvenience; public void createNotice(final NoticeRequest request) { noticeRepository.save(Notice.builder() @@ -25,11 +27,30 @@ public void createNotice(final NoticeRequest request) { } public void updateNotice(final NoticeRequest request, final Long noticeId) { - final Notice notice = noticeConvenience.getValidateExistNotice(noticeId); + final Notice notice = noticeConvenience.getValidateGlobalNotice(noticeId); notice.updateNotice(request.title(), request.description()); } public void deleteNotice(final Long noticeId) { - noticeRepository.delete(noticeConvenience.getValidateExistNotice(noticeId)); + noticeRepository.delete(noticeConvenience.getValidateGlobalNotice(noticeId)); + } + + public void createContestNotice(final Long contestId, final NoticeRequest request) { + contestConvenience.validateExistContest(contestId); + + noticeRepository.save(Notice.builder() + .contestId(contestId) + .title(request.title()) + .description(request.description()) + .build()); + } + + public void updateContestNotice(final NoticeRequest request, final Long contestId, final Long noticeId) { + final Notice notice = noticeConvenience.getValidateContestNotice(contestId, noticeId); + notice.updateNotice(request.title(), request.description()); + } + + public void deleteContestNotice(final Long contestId, final Long noticeId) { + noticeRepository.delete(noticeConvenience.getValidateContestNotice(contestId, noticeId)); } } diff --git a/src/main/java/com/opus/opus/modules/notice/application/NoticeQueryService.java b/src/main/java/com/opus/opus/modules/notice/application/NoticeQueryService.java index fb307f46..dfb7a705 100644 --- a/src/main/java/com/opus/opus/modules/notice/application/NoticeQueryService.java +++ b/src/main/java/com/opus/opus/modules/notice/application/NoticeQueryService.java @@ -1,6 +1,7 @@ package com.opus.opus.modules.notice.application; -import com.opus.opus.modules.notice.application.dto.NoticeConvenience; +import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.notice.application.convenience.NoticeConvenience; import com.opus.opus.modules.notice.application.dto.response.NoticeDetailResponse; import com.opus.opus.modules.notice.application.dto.response.NoticeSummaryResponse; import com.opus.opus.modules.notice.domain.Notice; @@ -18,14 +19,28 @@ public class NoticeQueryService { private final NoticeRepository noticeRepository; private final NoticeConvenience noticeConvenience; + private final ContestConvenience contestConvenience; public NoticeDetailResponse getNotice(final Long noticeId) { - final Notice notice = noticeConvenience.getValidateExistNotice(noticeId); + final Notice notice = noticeConvenience.getValidateGlobalNotice(noticeId); return NoticeDetailResponse.from(notice); } public List getAllNotices() { - return noticeRepository.findAllByOrderByCreatedAtDesc() + return noticeRepository.findAllByContestIdIsNullOrderByCreatedAtDesc() + .stream() + .map(NoticeSummaryResponse::from) + .toList(); + } + + public NoticeDetailResponse getContestNotice(final Long contestId, final Long noticeId) { + final Notice notice = noticeConvenience.getValidateContestNotice(contestId, noticeId); + return NoticeDetailResponse.from(notice); + } + + public List getAllContestNotices(final Long contestId) { + contestConvenience.validateExistContest(contestId); + return noticeRepository.findAllByContestIdOrderByCreatedAtDesc(contestId) .stream() .map(NoticeSummaryResponse::from) .toList(); diff --git a/src/main/java/com/opus/opus/modules/notice/application/dto/NoticeConvenience.java b/src/main/java/com/opus/opus/modules/notice/application/convenience/NoticeConvenience.java similarity index 53% rename from src/main/java/com/opus/opus/modules/notice/application/dto/NoticeConvenience.java rename to src/main/java/com/opus/opus/modules/notice/application/convenience/NoticeConvenience.java index 5662b43a..057e66fd 100644 --- a/src/main/java/com/opus/opus/modules/notice/application/dto/NoticeConvenience.java +++ b/src/main/java/com/opus/opus/modules/notice/application/convenience/NoticeConvenience.java @@ -1,4 +1,4 @@ -package com.opus.opus.modules.notice.application.dto; +package com.opus.opus.modules.notice.application.convenience; import static com.opus.opus.modules.notice.exception.NoticeExceptionType.NOT_FOUND_NOTICE; @@ -16,8 +16,14 @@ public class NoticeConvenience { private final NoticeRepository noticeRepository; - public Notice getValidateExistNotice(final Long noticeId) { - return noticeRepository.findById(noticeId).orElseThrow(() -> new NoticeException(NOT_FOUND_NOTICE)); + public Notice getValidateGlobalNotice(final Long noticeId) { + return noticeRepository.findByIdAndContestIdIsNull(noticeId) + .orElseThrow(() -> new NoticeException(NOT_FOUND_NOTICE)); + } + + public Notice getValidateContestNotice(final Long contestId, final Long noticeId) { + return noticeRepository.findByContestIdAndId(contestId, noticeId) + .orElseThrow(() -> new NoticeException(NOT_FOUND_NOTICE)); } } diff --git a/src/main/java/com/opus/opus/modules/notice/domain/dao/NoticeRepository.java b/src/main/java/com/opus/opus/modules/notice/domain/dao/NoticeRepository.java index 03ce1005..929106ec 100644 --- a/src/main/java/com/opus/opus/modules/notice/domain/dao/NoticeRepository.java +++ b/src/main/java/com/opus/opus/modules/notice/domain/dao/NoticeRepository.java @@ -2,9 +2,16 @@ import com.opus.opus.modules.notice.domain.Notice; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface NoticeRepository extends JpaRepository { - List findAllByOrderByCreatedAtDesc(); + List findAllByContestIdIsNullOrderByCreatedAtDesc(); + + List findAllByContestIdOrderByCreatedAtDesc(final Long contestId); + + Optional findByContestIdAndId(final Long contestId, final Long id); + + Optional findByIdAndContestIdIsNull(final Long id); } diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamCommentController.java b/src/main/java/com/opus/opus/modules/team/api/TeamCommentController.java new file mode 100644 index 00000000..fcf6bd1f --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/api/TeamCommentController.java @@ -0,0 +1,72 @@ +package com.opus.opus.modules.team.api; + +import com.opus.opus.global.security.annotation.LoginMember; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.TeamCommentCommandService; +import com.opus.opus.modules.team.application.TeamCommentQueryService; +import com.opus.opus.modules.team.application.dto.request.TeamCommentCreateRequest; +import com.opus.opus.modules.team.application.dto.request.TeamCommentUpdateRequest; +import com.opus.opus.modules.team.application.dto.response.TeamCommentResponse; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/teams/{teamId}/comments") +@Secured({"ROLE_회원", "ROLE_관리자"}) +public class TeamCommentController { + + private final TeamCommentCommandService teamCommentCommandService; + private final TeamCommentQueryService teamCommentQueryService; + + @PostMapping + public ResponseEntity createTeamComment( + @PathVariable final Long teamId, + @Valid @RequestBody final TeamCommentCreateRequest request, + @LoginMember final Member member + ) { + teamCommentCommandService.createComment(teamId, member.getId(), request.description()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping + public ResponseEntity> getTeamComments( + @PathVariable final Long teamId + ) { + List response = teamCommentQueryService.getComments(teamId); + return ResponseEntity.ok(response); + } + + @PatchMapping("/{commentId}") + public ResponseEntity updateTeamComment( + @PathVariable final Long teamId, + @PathVariable final Long commentId, + @Valid @RequestBody final TeamCommentUpdateRequest request, + @LoginMember final Member member + ) { + teamCommentCommandService.updateComment(teamId, commentId, member.getId(), request.description()); + return ResponseEntity.ok().build(); + } + + @DeleteMapping("/{commentId}") + public ResponseEntity deleteTeamComment( + @PathVariable final Long teamId, + @PathVariable final Long commentId, + @LoginMember final Member member + ) { + teamCommentCommandService.deleteComment(teamId, commentId, member.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamController.java b/src/main/java/com/opus/opus/modules/team/api/TeamController.java index 2030e5a6..0d586aed 100644 --- a/src/main/java/com/opus/opus/modules/team/api/TeamController.java +++ b/src/main/java/com/opus/opus/modules/team/api/TeamController.java @@ -79,4 +79,28 @@ public ResponseEntity deleteThumbnailImage(@PathVariable final Long teamId teamCommandService.deleteThumbnailImage(teamId); return ResponseEntity.noContent().build(); } + + @GetMapping("/{teamId}/image/posters") + public ResponseEntity getPosterImage(@PathVariable final Long teamId) { + final ImageResponse imageResponse = teamQueryService.getPosterImage(teamId); + + return ResponseEntity.ok() + .contentType(imageResponse.getMediaType()) + .body(imageResponse.resource()); + } + + @Secured({"ROLE_팀장", "ROLE_관리자", "ROLE_팀원"}) + @PostMapping("/{teamId}/image/posters") + public ResponseEntity savePosterImage(@PathVariable final Long teamId, + @RequestPart("image") final MultipartFile image) { + teamCommandService.savePosterImage(teamId, image); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @Secured({"ROLE_팀장", "ROLE_관리자", "ROLE_팀원"}) + @DeleteMapping("/{teamId}/image/posters") + public ResponseEntity deletePosterImage(@PathVariable final Long teamId) { + teamCommandService.deletePosterImage(teamId); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/opus/opus/modules/team/api/TeamMemberController.java b/src/main/java/com/opus/opus/modules/team/api/TeamMemberController.java new file mode 100644 index 00000000..8cb018cc --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/api/TeamMemberController.java @@ -0,0 +1,41 @@ +package com.opus.opus.modules.team.api; + +import com.opus.opus.modules.team.application.TeamMemberCommandService; +import com.opus.opus.modules.team.application.dto.request.TeamMemberCreateRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.annotation.Secured; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/teams/{teamId}/members") +@Secured("ROLE_관리자") +public class TeamMemberController { + + private final TeamMemberCommandService teamMemberCommandService; + + @PostMapping + public ResponseEntity createTeamMember(@PathVariable final Long teamId, + @Valid @RequestBody final TeamMemberCreateRequest request) { + teamMemberCommandService.createTeamMember(teamId, request.memberStudentId(), request.memberName(), + request.roleType()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping("/{memberId}") + public ResponseEntity deleteTeamMember(@PathVariable final Long teamId, + @PathVariable final Long memberId) { + teamMemberCommandService.deleteTeamMember(teamId, memberId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java index ae37ac1c..194f4009 100644 --- a/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java +++ b/src/main/java/com/opus/opus/modules/team/application/TeamCommandService.java @@ -1,5 +1,6 @@ package com.opus.opus.modules.team.application; +import static com.opus.opus.modules.file.domain.FileImageType.POSTER; import static com.opus.opus.modules.file.domain.FileImageType.PREVIEW; import static com.opus.opus.modules.file.domain.FileImageType.THUMBNAIL; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; @@ -8,6 +9,7 @@ import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.modules.file.domain.File; +import com.opus.opus.modules.file.domain.FileImageType; import com.opus.opus.modules.file.domain.dao.FileRepository; import com.opus.opus.modules.file.exception.FileException; import com.opus.opus.modules.team.application.convenience.TeamConvenience; @@ -38,27 +40,34 @@ public void savePreviewImages(final Long teamId, final List image public void deletePreviewImages(final Long teamId, final List ids) { teamConvenience.validateExistTeam(teamId); - ids.forEach(fileId -> { - fileRepository.findById(fileId).ifPresent(this::checkWebpConverted); - fileStorageUtil.deleteFile(fileId); - }); + ids.forEach(fileStorageUtil::deleteFile); } public void saveThumbnailImage(final Long teamId, final MultipartFile image) { teamConvenience.validateExistTeam(teamId); - fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, THUMBNAIL).ifPresent(existingFile -> { - checkWebpConverted(existingFile); - fileStorageUtil.deleteFile(existingFile.getId()); - }); + deleteIfExists(teamId, THUMBNAIL); fileStorageUtil.storeFile(image, teamId, TEAM, THUMBNAIL); } public void deleteThumbnailImage(final Long teamId) { teamConvenience.validateExistTeam(teamId); - fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, THUMBNAIL).ifPresent(existingFile -> { - checkWebpConverted(existingFile); - fileStorageUtil.deleteFile(existingFile.getId()); - }); + deleteIfExists(teamId, THUMBNAIL); + } + + public void savePosterImage(final Long teamId, final MultipartFile image) { + teamConvenience.validateExistTeam(teamId); + deleteIfExists(teamId, POSTER); + fileStorageUtil.storeFile(image, teamId, TEAM, POSTER); + } + + public void deletePosterImage(final Long teamId) { + teamConvenience.validateExistTeam(teamId); + deleteIfExists(teamId, POSTER); + } + + private void deleteIfExists(final Long teamId, final FileImageType imageType) { + fileRepository.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, imageType) + .ifPresent(existingFile -> fileStorageUtil.deleteFile(existingFile.getId())); } private void checkPreviewLimit(final Long teamId, final List images) { @@ -67,10 +76,4 @@ private void checkPreviewLimit(final Long teamId, final List imag throw new FileException(EXCEED_PREVIEW_LIMIT); } } - - private void checkWebpConverted(final File existingFile) { - if (!existingFile.getIsWebpConverted()) { - throw new FileException(NOT_WEBP_CONVERTED); - } - } } diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamCommentCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamCommentCommandService.java new file mode 100644 index 00000000..c96eb2d8 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/TeamCommentCommandService.java @@ -0,0 +1,71 @@ +package com.opus.opus.modules.team.application; + +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.COMMENT_NOT_BELONG_TO_TEAM; +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.NOT_FOUND_COMMENT; +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.NOT_OWNER_COMMENT; + +import com.opus.opus.modules.team.application.convenience.TeamConvenience; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamComment; +import com.opus.opus.modules.team.domain.dao.TeamCommentRepository; +import com.opus.opus.modules.team.exception.TeamCommentException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class TeamCommentCommandService { + + private final TeamCommentRepository teamCommentRepository; + + private final TeamConvenience teamConvenience; + + public void createComment(final Long teamId, final Long memberId, final String description) { + final Team team = teamConvenience.getValidateExistTeam(teamId); + + teamCommentRepository.save(TeamComment.builder() + .description(description) + .memberId(memberId) + .team(team) + .build()); + } + + public void updateComment(final Long teamId, final Long commentId, final Long memberId, final String newDescription) { + teamConvenience.validateExistTeam(teamId); + final TeamComment comment = getValidateExistComment(commentId); + + validateCommentBelongsToTeam(comment, teamId); + isMine(comment, memberId); + + comment.updateDescription(newDescription); + } + + public void deleteComment(final Long teamId, final Long commentId, final Long memberId) { + teamConvenience.validateExistTeam(teamId); + final TeamComment comment = getValidateExistComment(commentId); + + validateCommentBelongsToTeam(comment, teamId); + isMine(comment, memberId); + + teamCommentRepository.delete(comment); + } + + private void isMine(final TeamComment comment, final Long memberId) { + if (!comment.isMine(memberId)) { + throw new TeamCommentException(NOT_OWNER_COMMENT); + } + } + + private TeamComment getValidateExistComment(final Long commentId) { + return teamCommentRepository.findById(commentId).orElseThrow(() -> new TeamCommentException(NOT_FOUND_COMMENT)); + } + + private void validateCommentBelongsToTeam(final TeamComment comment, final Long teamId) { + if (!comment.getTeam().getId().equals(teamId)) { + throw new TeamCommentException(COMMENT_NOT_BELONG_TO_TEAM); + } + } +} + diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamCommentQueryService.java b/src/main/java/com/opus/opus/modules/team/application/TeamCommentQueryService.java new file mode 100644 index 00000000..3e0ee717 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/TeamCommentQueryService.java @@ -0,0 +1,52 @@ +package com.opus.opus.modules.team.application; + +import static java.util.stream.Collectors.toMap; + +import com.opus.opus.modules.member.application.convenience.MemberConvenience; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.convenience.TeamConvenience; +import com.opus.opus.modules.team.application.dto.response.TeamCommentResponse; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamComment; +import com.opus.opus.modules.team.domain.dao.TeamCommentRepository; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class TeamCommentQueryService { + + private final TeamCommentRepository teamCommentRepository; + + private final MemberConvenience memberConvenience; + private final TeamConvenience teamConvenience; + + public List getComments(final Long teamId) { + final Team team = teamConvenience.getValidateExistTeam(teamId); + final List comments = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()); + + final List memberIds = comments.stream() + .map(TeamComment::getMemberId) + .distinct() + .toList(); + + final Map memberIdNameMap = memberConvenience.findAllById(memberIds) + .stream() + .collect(toMap(Member::getId, Member::getName)); + + return comments.stream() + .map(comment -> new TeamCommentResponse( + comment.getId(), + comment.getDescription(), + comment.getMemberId(), + memberIdNameMap.get(comment.getMemberId()), + team.getId() + )) + .toList(); + } +} + diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamMemberCommandService.java b/src/main/java/com/opus/opus/modules/team/application/TeamMemberCommandService.java new file mode 100644 index 00000000..9468dce7 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/TeamMemberCommandService.java @@ -0,0 +1,46 @@ +package com.opus.opus.modules.team.application; + +import com.opus.opus.modules.member.application.convenience.MemberConvenience; +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.TeamMemberConvenience; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamMember; +import com.opus.opus.modules.team.domain.TeamMemberRoleType; +import com.opus.opus.modules.team.domain.dao.TeamMemberRepository; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class TeamMemberCommandService { + + private final TeamMemberRepository teamMemberRepository; + + private final TeamConvenience teamConvenience; + private final TeamMemberConvenience teamMemberConvenience; + private final MemberConvenience memberConvenience; + + public void createTeamMember(final Long teamId, final String studentId, final String name, + final TeamMemberRoleType roleType) { + final Team team = teamConvenience.getValidateExistTeam(teamId); + final Member member = memberConvenience.getOrCreateFakeMember(studentId, name); + teamMemberConvenience.checkIsDuplicateTeamMember(teamId, member.getId()); + teamMemberRepository.save( + TeamMember.builder() + .memberId(member.getId()) + .team(team) + .roles(Set.of(roleType)) + .build() + ); + } + + public void deleteTeamMember(final Long teamId, final Long memberId) { + teamConvenience.getValidateExistTeam(teamId); + final TeamMember teamMember = teamMemberConvenience.getValidateExistTeamMember(teamId, memberId); + teamMemberRepository.delete(teamMember); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java b/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java index 4543f546..cb2f01d6 100644 --- a/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java +++ b/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java @@ -1,5 +1,7 @@ package com.opus.opus.modules.team.application; +import static com.opus.opus.modules.file.domain.FileImageType.POSTER; +import static com.opus.opus.modules.file.domain.FileImageType.PREVIEW; import static com.opus.opus.modules.file.domain.FileImageType.THUMBNAIL; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_EXISTS_PREVIEW; @@ -8,6 +10,7 @@ import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.modules.file.application.convenience.FileConvenience; import com.opus.opus.modules.file.domain.File; +import com.opus.opus.modules.file.domain.FileImageType; import com.opus.opus.modules.file.domain.dao.FileRepository; import com.opus.opus.modules.file.exception.FileException; import com.opus.opus.modules.team.application.convenience.TeamConvenience; @@ -37,8 +40,16 @@ public ImageResponse getPreviewImage(final Long teamId, final Long imageId) { } public ImageResponse getThumbnailImage(final Long teamId) { + return getImage(teamId, THUMBNAIL); + } + + public ImageResponse getPosterImage(final Long teamId) { + return getImage(teamId, POSTER); + } + + private ImageResponse getImage(final Long teamId, final FileImageType fileImageType) { teamConvenience.validateExistTeam(teamId); - final File findFile = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, THUMBNAIL); + final File findFile = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, fileImageType); checkImageConverted(findFile); final Pair storageResult = fileStorageUtil.findFileAndType(findFile.getId()); return new ImageResponse(storageResult.a, storageResult.b); diff --git a/src/main/java/com/opus/opus/modules/team/application/convenience/TeamMemberConvenience.java b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamMemberConvenience.java new file mode 100644 index 00000000..30cafa69 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/convenience/TeamMemberConvenience.java @@ -0,0 +1,30 @@ +package com.opus.opus.modules.team.application.convenience; + +import static com.opus.opus.modules.team.exception.TeamMemberExceptionType.TEAM_MEMBER_ALREADY_EXISTS; +import static com.opus.opus.modules.team.exception.TeamMemberExceptionType.TEAM_MEMBER_NOT_FOUND_IN_TEAM; + +import com.opus.opus.modules.team.domain.TeamMember; +import com.opus.opus.modules.team.domain.dao.TeamMemberRepository; +import com.opus.opus.modules.team.exception.TeamMemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TeamMemberConvenience { + + private final TeamMemberRepository teamMemberRepository; + + public void checkIsDuplicateTeamMember(final Long teamId, final Long memberId) { + if (teamMemberRepository.existsByTeamIdAndMemberId(teamId, memberId)) { + throw new TeamMemberException(TEAM_MEMBER_ALREADY_EXISTS); + } + } + + public TeamMember getValidateExistTeamMember(final Long teamId, final Long memberId) { + return teamMemberRepository.findByTeamIdAndMemberId(teamId, memberId) + .orElseThrow(() -> new TeamMemberException(TEAM_MEMBER_NOT_FOUND_IN_TEAM)); + } +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentCreateRequest.java b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentCreateRequest.java new file mode 100644 index 00000000..2ca645a7 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentCreateRequest.java @@ -0,0 +1,9 @@ +package com.opus.opus.modules.team.application.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record TeamCommentCreateRequest( + + @NotBlank(message = "작성할 댓글 내용은 필수입니다.") + String description +) {} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentUpdateRequest.java b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentUpdateRequest.java new file mode 100644 index 00000000..6e1468e1 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamCommentUpdateRequest.java @@ -0,0 +1,10 @@ +package com.opus.opus.modules.team.application.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record TeamCommentUpdateRequest( + + @NotBlank(message = "수정할 댓글 내용은 비어 있을 수 없습니다.") + String description +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamMemberCreateRequest.java b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamMemberCreateRequest.java new file mode 100644 index 00000000..1f9a6809 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/request/TeamMemberCreateRequest.java @@ -0,0 +1,20 @@ +package com.opus.opus.modules.team.application.dto.request; + +import com.opus.opus.modules.team.domain.TeamMemberRoleType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record TeamMemberCreateRequest( + + @NotBlank(message = "추가할 팀원명은 비어 있을 수 없습니다.") + String memberName, + + @NotBlank(message = "추가할 팀원학번은 비어 있을 수 없습니다.") + @Pattern(regexp = "^\\d{9}$", message = "학번은 9자리 숫자여야 합니다.") + String memberStudentId, + + @NotNull(message = "권한을 입력해 주세요.") + TeamMemberRoleType roleType +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamCommentResponse.java b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamCommentResponse.java new file mode 100644 index 00000000..324df87b --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/application/dto/response/TeamCommentResponse.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.team.application.dto.response; + +public record TeamCommentResponse( + + Long commentId, + String description, + Long memberId, + String memberName, + Long teamId +) { +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamComment.java b/src/main/java/com/opus/opus/modules/team/domain/TeamComment.java index b4069636..66806d6e 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/TeamComment.java +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamComment.java @@ -49,4 +49,11 @@ private TeamComment(final String description, final Long memberId, final Team te this.isDeleted = false; } + public void updateDescription(final String newDescription) { + this.description = newDescription; + } + + public boolean isMine(Long memberId) { + return this.memberId.equals(memberId); + } } diff --git a/src/main/java/com/opus/opus/modules/team/domain/TeamMember.java b/src/main/java/com/opus/opus/modules/team/domain/TeamMember.java index dce5d288..7b25c062 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/TeamMember.java +++ b/src/main/java/com/opus/opus/modules/team/domain/TeamMember.java @@ -1,5 +1,6 @@ package com.opus.opus.modules.team.domain; +import static jakarta.persistence.FetchType.EAGER; import static jakarta.persistence.FetchType.LAZY; import com.opus.opus.global.base.BaseEntity; @@ -43,7 +44,7 @@ public class TeamMember extends BaseEntity { @JoinColumn(name = "team_id", nullable = false) private Team team; - @ElementCollection(fetch = LAZY) + @ElementCollection(fetch = EAGER) @CollectionTable(name = "team_member_roles", joinColumns = @JoinColumn(name = "team_member_id")) @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false, length = MAX_ROLE_NAME_LENGTH) diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java new file mode 100644 index 00000000..d501f110 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.team.domain.dao; + +import com.opus.opus.modules.team.domain.TeamComment; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamCommentRepository extends JpaRepository { + List findAllByTeamIdOrderByIdDesc(Long id); + + void deleteAllByTeamId(Long teamId); +} diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamMemberRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamMemberRepository.java new file mode 100644 index 00000000..a70de88d --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamMemberRepository.java @@ -0,0 +1,12 @@ +package com.opus.opus.modules.team.domain.dao; + +import com.opus.opus.modules.team.domain.TeamMember; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamMemberRepository extends JpaRepository { + + boolean existsByTeamIdAndMemberId(final Long teamId, final Long memberId); + + Optional findByTeamIdAndMemberId(final Long teamId, final Long memberId); +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamCommentException.java b/src/main/java/com/opus/opus/modules/team/exception/TeamCommentException.java new file mode 100644 index 00000000..35fcc96b --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamCommentException.java @@ -0,0 +1,26 @@ +package com.opus.opus.modules.team.exception; + + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class TeamCommentException extends BaseException { + + private final TeamCommentExceptionType exceptionType; + + public TeamCommentException(final TeamCommentExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + public TeamCommentException(final TeamCommentExceptionType exceptionType, final String message) { + super(message); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} + diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamCommentExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamCommentExceptionType.java new file mode 100644 index 00000000..6a0f8e81 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamCommentExceptionType.java @@ -0,0 +1,30 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum TeamCommentExceptionType implements BaseExceptionType { + + NOT_FOUND_COMMENT(HttpStatus.NOT_FOUND, "존재하지 않는 댓글입니다."), + NOT_OWNER_COMMENT(HttpStatus.FORBIDDEN, "댓글 작성자가 아닙니다."), + COMMENT_NOT_BELONG_TO_TEAM(HttpStatus.BAD_REQUEST, "댓글이 해당 팀에 속해있지 않습니다.") + ; + + private final HttpStatus httpStatus; + private final String errorMessage; + + TeamCommentExceptionType(final HttpStatus httpStatus, final String errorMessage) { + this.httpStatus = httpStatus; + this.errorMessage = errorMessage; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return errorMessage; + } +} diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamMemberException.java b/src/main/java/com/opus/opus/modules/team/exception/TeamMemberException.java new file mode 100644 index 00000000..0b593baf --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamMemberException.java @@ -0,0 +1,25 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseException; +import com.opus.opus.global.base.BaseExceptionType; + +public class TeamMemberException extends BaseException { + + private final TeamMemberExceptionType exceptionType; + + public TeamMemberException(final TeamMemberExceptionType exceptionType) { + super(exceptionType.errorMessage()); + this.exceptionType = exceptionType; + } + + public TeamMemberException(final TeamMemberExceptionType exceptionType, final String message) { + super(message); + this.exceptionType = exceptionType; + } + + @Override + public BaseExceptionType exceptionType() { + return exceptionType; + } +} + diff --git a/src/main/java/com/opus/opus/modules/team/exception/TeamMemberExceptionType.java b/src/main/java/com/opus/opus/modules/team/exception/TeamMemberExceptionType.java new file mode 100644 index 00000000..813d7fc5 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/team/exception/TeamMemberExceptionType.java @@ -0,0 +1,28 @@ +package com.opus.opus.modules.team.exception; + +import com.opus.opus.global.base.BaseExceptionType; +import org.springframework.http.HttpStatus; + +public enum TeamMemberExceptionType implements BaseExceptionType { + + TEAM_MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 팀에 등록된 팀원입니다."), + TEAM_MEMBER_NOT_FOUND_IN_TEAM(HttpStatus.NOT_FOUND, "해당 팀의 팀원이 아닙니다."); + + private final HttpStatus httpStatus; + private final String errorMessage; + + TeamMemberExceptionType(final HttpStatus httpStatus, final String errorMessage) { + this.httpStatus = httpStatus; + this.errorMessage = errorMessage; + } + + @Override + public HttpStatus httpStatus() { + return httpStatus; + } + + @Override + public String errorMessage() { + return errorMessage; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1acf4310..f0fcb33b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,4 +24,3 @@ cors: allow: origins: https://test.url methods: GET, POST, PUT, DELETE, PATCH, OPTIONS - diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 8bba4c45..7b5b613d 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -88,7 +88,7 @@ CREATE TABLE `file` ( `created_at` datetime(6) DEFAULT NULL, `updated_at` datetime(6) DEFAULT NULL, `file_path` varchar(255) NOT NULL, - `image_type` enum('BANNER','PREVIEW','THUMBNAIL') NOT NULL, + `image_type` enum('BANNER','PREVIEW','THUMBNAIL','POSTER') NOT NULL, `is_webp_converted` bit(1) NOT NULL, `name` varchar(255) NOT NULL, `reference_id` bigint NOT 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 00000000..f80f4849 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestCategoryFixture.java @@ -0,0 +1,12 @@ +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 00000000..2063d7d8 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestFixture.java @@ -0,0 +1,21 @@ +package com.opus.opus.contest; + +import com.opus.opus.modules.contest.domain.Contest; + +public class ContestFixture { + + + public static Contest createContest() { + return Contest.builder() + .contestName("제 1회 테스트 대회") + .categoryId(1L) + .build(); + } + + public static Contest createContestWithCategoryId(final Long categoryId) { + return Contest.builder() + .contestName("테스트 대회") + .categoryId(categoryId) + .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 new file mode 100644 index 00000000..62725729 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -0,0 +1,128 @@ +package com.opus.opus.contest.application; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.VOTE_END_PRECEDE_VOTE_START; +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.VoteUpdateRequest; +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.exception.ContestException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class ContestCommandServiceTest extends IntegrationTest { + + @Autowired + private ContestCommandService contestCommandService; + + @Autowired + private ContestRepository contestRepository; + + private Contest contest; + private static final Integer MAX_VOTES_LIMIT = 5; + + @BeforeEach + void setUp() { + contest = contestRepository.save(ContestFixture.createContestWithCategoryId(1L)); + } + + @Test + @DisplayName("[성공] 투표 기간 수정 시 시작일과 종료일이 정상적으로 업데이트된다.") + void 투표_기간_수정_시_시작일과_종료일이_정상적으로_업데이트된다() { + // given + final LocalDateTime originalStartAt = contest.getVoteStartAt(); + final LocalDateTime originalEndAt = contest.getVoteEndAt(); + + final LocalDateTime newStartAt = LocalDateTime.now().plusDays(1); + final LocalDateTime newEndAt = LocalDateTime.now().plusDays(5); + final VoteUpdateRequest request = new VoteUpdateRequest(newStartAt, newEndAt); + + // when + contestCommandService.updateVotePeriod(contest.getId(), request); + + // then + final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); + assertThat(updatedContest.getVoteStartAt()).isNotEqualTo(originalStartAt); + assertThat(updatedContest.getVoteEndAt()).isNotEqualTo(originalEndAt); + + assertThat(updatedContest.getVoteStartAt()).isEqualTo(newStartAt); + assertThat(updatedContest.getVoteEndAt()).isEqualTo(newEndAt); + } + + @Test + @DisplayName("[실패] 투표 종료일이 시작일보다 앞서면 예외가 발생한다.") + void 투표_종료일이_시작일보다_앞서면_예외가_발생한다() { + final LocalDateTime startAt = LocalDateTime.now().plusDays(5); + final LocalDateTime endAt = LocalDateTime.now().plusDays(1); + final VoteUpdateRequest request = new VoteUpdateRequest(startAt, endAt); + + assertThatThrownBy(() -> {contestCommandService.updateVotePeriod(contest.getId(), request);}) + .isInstanceOf(ContestException.class) + .hasMessage(VOTE_END_PRECEDE_VOTE_START.errorMessage()); + } + + @Test + @DisplayName("[성공] 최대 투표 개수가 정상적으로 설정된다.") + void 최대_투표_개수가_정상적으로_설정된다() { + contestCommandService.updateMaxVotesLimit(contest.getId(), MAX_VOTES_LIMIT); + + final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); + assertThat(updatedContest.getMaxVotesLimit()).isEqualTo(MAX_VOTES_LIMIT); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 최대 투표 개수는 설정할 수 없다.") + void 존재하지_않는_대회의_최대_투표_개수는_설정할_수_없다() { + final Long invalidContestId = 999L; + + assertThatThrownBy(() -> { + contestCommandService.updateMaxVotesLimit(invalidContestId, MAX_VOTES_LIMIT); + }).isInstanceOf(ContestException.class).hasMessage(NOT_FOUND_CONTEST.errorMessage()); + } + + @Test + @DisplayName("[실패] 투표 진행 중에는 최대 투표 개수를 변경할 수 있다.") + void 투표_진행_중에는_최대_투표_개수를_변경할_수_있다() { + final LocalDateTime now = LocalDateTime.now(); + contest.updateVotePeriod(now.minusDays(1), now.plusDays(1)); + + assertThatThrownBy(() -> { + contestCommandService.updateMaxVotesLimit(contest.getId(), MAX_VOTES_LIMIT); + }).isInstanceOf(ContestException.class) + .hasMessage(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD.errorMessage()); + } + + @Test + @DisplayName("[성공] 투표 시작 전에는 최대 투표 개수를 변경할 수 있다.") + void 투표_시작_전에는_최대_투표_개수를_변경할_수_있다() { + final LocalDateTime now = LocalDateTime.now(); + contest.updateVotePeriod(now.plusDays(1), now.plusDays(2)); + + contestCommandService.updateMaxVotesLimit(contest.getId(), MAX_VOTES_LIMIT); + + final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); + assertThat(updatedContest.getMaxVotesLimit()).isEqualTo(MAX_VOTES_LIMIT); + } + + @Test + @DisplayName("[성공] 투표 종료 후에는 최대 투표 개수를 변경할 수 있다.") + void 투표_종료_후에는_최대_투표_개수를_변경할_수_있다() { + final LocalDateTime now = LocalDateTime.now(); + contest.updateVotePeriod(now.minusDays(2), now.minusDays(1)); + assertThat(contest.getMaxVotesLimit()).isEqualTo(0); // 변경 전 값 검증 + + contestCommandService.updateMaxVotesLimit(contest.getId(), MAX_VOTES_LIMIT); + + final Contest updatedContest = contestRepository.findById(contest.getId()).orElseThrow(); // 변경 후 값 검증 + assertThat(updatedContest.getMaxVotesLimit()).isEqualTo(MAX_VOTES_LIMIT); + } +} \ No newline at end of file diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java new file mode 100644 index 00000000..335a5f7e --- /dev/null +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -0,0 +1,77 @@ +package com.opus.opus.contest.application; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +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.ContestQueryService; +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.domain.Contest; +import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.exception.ContestException; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +public class ContestQueryServiceTest extends IntegrationTest { + + @Autowired + private ContestQueryService contestQueryService; + + @Autowired + private ContestRepository contestRepository; + + private Contest contest; + + @BeforeEach + void setUp() { + contest = contestRepository.save(ContestFixture.createContestWithCategoryId(1L)); + } + + @Test + @DisplayName("[성공] 투표 기간 조회 시 저장된 기간을 반환한다.") + void 투표_기간_조회_시_저장된_기간을_반환한다() { + LocalDateTime startAt = LocalDateTime.now().plusDays(2); + LocalDateTime endAt = LocalDateTime.now().plusDays(7); + contest.updateVotePeriod(startAt, endAt); + + VotePeriodResponse response = contestQueryService.getVotePeriod(contest.getId()); + + assertThat(response.voteStartAt()).isEqualTo(startAt); + assertThat(response.voteEndAt()).isEqualTo(endAt); + } + + @Test + @DisplayName("[성공] 최대 투표 개수를 조회할 수 있다.") + void 최대_투표_개수를_조회할_수_있다() { + final Integer maxVotesLimit = 5; + contest.updateMaxVotesLimit(maxVotesLimit); + + final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contest.getId()); + + assertThat(response.maxVotesLimit()).isEqualTo(maxVotesLimit); + } + + @Test + @DisplayName("[성공] 기본 최대 투표 개수는 0이다.") + void 기본_최대_투표_개수는_0이다() { + final ContestVotesLimitResponse response = contestQueryService.getMaxVotesLimit(contest.getId()); + + assertThat(response.maxVotesLimit()).isEqualTo(0); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 최대 투표 개수는 조회할 수 없다.") + void 존재하지_않는_대회의_최대_투표_개수는_조회할_수_없다() { + final Long invalidContestId = 999L; + + assertThatThrownBy(() -> { + contestQueryService.getMaxVotesLimit(invalidContestId); + }).isInstanceOf(ContestException.class).hasMessage(NOT_FOUND_CONTEST.errorMessage()); + } +} \ No newline at end of file diff --git a/src/test/java/com/opus/opus/helper/IntegrationTest.java b/src/test/java/com/opus/opus/helper/IntegrationTest.java index 0ade7019..1b3247ae 100644 --- a/src/test/java/com/opus/opus/helper/IntegrationTest.java +++ b/src/test/java/com/opus/opus/helper/IntegrationTest.java @@ -1,8 +1,10 @@ package com.opus.opus.helper; import com.opus.opus.global.security.JwtProvider; +import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.global.util.MailUtil; import com.opus.opus.global.util.AuthRedisUtil; +import com.opus.opus.global.util.oauth.component.GoogleOauth; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -31,6 +33,12 @@ public abstract class IntegrationTest extends ApiTestHelper { @MockitoBean private MailUtil mailUtil; + @MockitoBean + protected GoogleOauth googleOauth; + + @MockitoBean + protected FileStorageUtil fileStorageUtil; + @BeforeEach void setUp(final WebApplicationContext context) { this.mockMvc = MockMvcBuilders.webAppContextSetup(context) diff --git a/src/test/java/com/opus/opus/member/MemberFixture.java b/src/test/java/com/opus/opus/member/MemberFixture.java index 58ded82f..6086af91 100644 --- a/src/test/java/com/opus/opus/member/MemberFixture.java +++ b/src/test/java/com/opus/opus/member/MemberFixture.java @@ -9,11 +9,21 @@ public class MemberFixture { public static Member createMember() { return Member.builder() - .name("이옵스") + .name("테스트회원") .email("example@pusan.ac.kr") .password("{noop}123456789") .studentId("202612345") .roles(Set.of(ROLE_회원)) .build(); } + + public static Member createMemberWithUniqueNum(int number) { + return Member.builder() + .name("테스트회원" + number) + .email("example" + number + "@pusan.ac.kr") + .password("{noop}123456789") + .studentId("20211234" + number) + .roles(Set.of(ROLE_회원)) + .build(); + } } diff --git a/src/test/java/com/opus/opus/member/application/MemberCommandServiceTest.java b/src/test/java/com/opus/opus/member/application/MemberCommandServiceTest.java index 43800474..92ad002d 100644 --- a/src/test/java/com/opus/opus/member/application/MemberCommandServiceTest.java +++ b/src/test/java/com/opus/opus/member/application/MemberCommandServiceTest.java @@ -1,12 +1,22 @@ package com.opus.opus.member.application; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.OAUTH_AUTHORIZATION_FAILED; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.SOCIAL_LOGIN_FAILED_AUTH_CODE; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.USER_DENIED_AUTHORIZATION; import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_MATCH_EMAIL_AUTH_CODE; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_PUSAN_UNIVERSITY_EMAIL; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_VERIFIED_EMAIL_AUTH; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertTrue; - +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.opus.opus.global.util.oauth.dto.GoogleUser; +import com.opus.opus.global.util.oauth.dto.OAuthResult; +import com.opus.opus.global.util.oauth.exception.OAuthException; import com.opus.opus.helper.IntegrationTest; import com.opus.opus.member.MemberFixture; import com.opus.opus.modules.member.application.MemberCommandService; @@ -24,6 +34,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; public class MemberCommandServiceTest extends IntegrationTest { @@ -35,11 +49,24 @@ public class MemberCommandServiceTest extends IntegrationTest { private Member teamLeader; private EmailAuthRequest emailAuthRequest; + private MockHttpServletRequest mockRequest; @BeforeEach void setUp() { teamLeader = memberRepository.save(MemberFixture.createMember()); emailAuthRequest = new EmailAuthRequest("qwer1234@pusan.ac.kr"); + + when(googleOauth.createOAuthStateKey(anyString(), anyString())).thenCallRealMethod(); // state key값이 null이 되는 문제 방지하기 위해 실제 메서드 호출 + } + + private void setUpMockRequest() { + mockRequest = new MockHttpServletRequest(); + mockRequest.setSession(new MockHttpSession()); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(mockRequest)); + } + + private String getSessionId() { + return mockRequest.getSession().getId(); } @Test @@ -189,4 +216,195 @@ void setUp() { assertThat(response.memberId()).isEqualTo(teamLeader.getId()); assertThat(response.token()).isNotEmpty(); } + + @Test + @DisplayName("[실패] state가 null이면 인증 실패한다") + void state가_null이면_인증_실패한다() { + final String code = "code"; + final String state = null; + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback(code, state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(OAUTH_AUTHORIZATION_FAILED.errorMessage()); + } + + @Test + @DisplayName("[실패] state가 빈 문자열이면 인증 실패한다") + void state가_빈_문자열이면_인증_실패한다() { + final String code = "code"; + final String state = ""; + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback(code, state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(OAUTH_AUTHORIZATION_FAILED.errorMessage()); + } + + @Test + @DisplayName("[실패] Redis에 저장되지 않은 state면 인증 실패한다") + void Redis에_저장되지_않은_state면_인증_실패한다() { + final String code = "code"; + final String state = "invalidState"; + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback(code, state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(OAUTH_AUTHORIZATION_FAILED.errorMessage()); + } + + @Test + @DisplayName("[실패] 다른 세션의 state로는 인증 실패한다 (CSRF 방어)") + void 다른_세션의_state로는_인증_실패한다() { + setUpMockRequest(); + final String attackerSessionId = "attacker-session-id"; + final String state = "state"; + final String attackerStateKey = "oauth:state:" + attackerSessionId + ":" + state; + authRedisUtil.set(attackerStateKey, "valid", 5L, TimeUnit.MINUTES); + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback("code", state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(OAUTH_AUTHORIZATION_FAILED.errorMessage()); + } + + @Test + @DisplayName("[실패] error 파라미터가 있으면 사용자 권한 거부로 처리된다") + void error_파라미터가_있으면_사용자_권한_거부로_처리된다() { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback("code", state, "access_denied"); + }).isInstanceOf(OAuthException.class) + .hasMessage(USER_DENIED_AUTHORIZATION.errorMessage()); + } + + @Test + @DisplayName("[실패] code가 null이면 인증 실패한다") + void code가_null이면_인증_실패한다() { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback(null, state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(SOCIAL_LOGIN_FAILED_AUTH_CODE.errorMessage()); + } + + @Test + @DisplayName("[실패] code가 빈 문자열이면 인증 실패한다") + void code가_빈_문자열이면_인증_실패한다() { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + + assertThatThrownBy(() -> { + memberCommandService.getGoogleOAuthCallback("", state, null); + }).isInstanceOf(OAuthException.class) + .hasMessage(SOCIAL_LOGIN_FAILED_AUTH_CODE.errorMessage()); + } + + @Test + @DisplayName("[성공] 기존 회원은 OAuth 로그인 처리된다") + void 기존_회원은_OAuth_로그인_처리된다() throws Exception { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + final GoogleUser mockGoogleUser = new GoogleUser(teamLeader.getEmail(), teamLeader.getName()); + final OAuthResult mockResult = new OAuthResult<>(mockGoogleUser, "accessToken", "refreshToken"); + when(googleOauth.getUserInfoByCode(anyString(), eq(GoogleUser.class))) + .thenReturn(mockResult); + + SignInResponse response = memberCommandService.getGoogleOAuthCallback("code", state, null); + + assertThat(response.memberId()).isEqualTo(teamLeader.getId()); + assertThat(response.token()).isNotEmpty(); + assertThat(memberRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("[성공] 신규 회원은 자동 가입 후 OAuth 로그인 처리된다") + void 신규_회원은_자동_가입_후_OAuth_로그인_처리된다() throws Exception { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + final GoogleUser mockGoogleUser = new GoogleUser("kty@gmail.com", "김태윤"); + final OAuthResult mockResult = new OAuthResult<>(mockGoogleUser, "accessToken", "refreshToken"); + when(googleOauth.getUserInfoByCode(anyString(), eq(GoogleUser.class))) + .thenReturn(mockResult); + + SignInResponse response = memberCommandService.getGoogleOAuthCallback("code", state, null); + + assertThat(response.name()).isEqualTo("김태윤"); + assertThat(response.token()).isNotEmpty(); + assertThat(memberRepository.count()).isEqualTo(2); + Member newMember = memberRepository.findByEmail("kty@gmail.com").orElseThrow(); + assertThat(newMember.getStudentId()).startsWith("fake_"); + } + + @Test + @DisplayName("[성공] 검증 성공 시 state가 Redis에서 삭제된다") + void 검증_성공_시_state가_Redis에서_삭제된다() throws Exception { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + final GoogleUser mockGoogleUser = new GoogleUser(teamLeader.getEmail(), teamLeader.getName()); + final OAuthResult mockResult = new OAuthResult<>(mockGoogleUser, "accessToken", "refreshToken"); + when(googleOauth.getUserInfoByCode(anyString(), eq(GoogleUser.class))) + .thenReturn(mockResult); + + memberCommandService.getGoogleOAuthCallback("code", state, null); + + assertThat(authRedisUtil.exists(stateKey)).isFalse(); + } + + @Test + @DisplayName("[성공] OAuth 로그인 시 토큰이 Redis에 저장된다") + void OAuth_로그인_시_토큰이_Redis에_저장된다() throws Exception { + setUpMockRequest(); + final String state = "state"; + final String stateKey = "oauth:state:" + getSessionId() + ":" + state; + authRedisUtil.set(stateKey, "valid", 5L, TimeUnit.MINUTES); + final GoogleUser mockGoogleUser = new GoogleUser(teamLeader.getEmail(), teamLeader.getName()); + final OAuthResult mockResult = new OAuthResult<>(mockGoogleUser, "testAccessToken", "testRefreshToken"); + when(googleOauth.getUserInfoByCode(anyString(), eq(GoogleUser.class))) + .thenReturn(mockResult); + + memberCommandService.getGoogleOAuthCallback("code", state, null); + + final String tokenKey = "oauth:google:token:" + teamLeader.getId(); + assertThat(authRedisUtil.exists(tokenKey)).isTrue(); + assertThat(authRedisUtil.get(tokenKey)).isEqualTo("testAccessToken:testRefreshToken"); + } + + @Test + @DisplayName("[성공] 구글 연동 해제 시 Redis 토큰이 삭제된다") + void 구글_연동_해제_시_Redis_토큰이_삭제된다() { + final String tokenKey = "oauth:google:token:" + teamLeader.getId(); + authRedisUtil.set(tokenKey, "accessToken:refreshToken", 3L, TimeUnit.HOURS); + + memberCommandService.unlinkGoogleAccount(teamLeader.getId()); + + assertThat(authRedisUtil.exists(tokenKey)).isFalse(); + } + + @Test + @DisplayName("[성공] 구글 연동 해제 시 revokeToken이 호출된다") + void 구글_연동_해제_시_revokeToken이_호출된다() { + final String tokenKey = "oauth:google:token:" + teamLeader.getId(); + authRedisUtil.set(tokenKey, "testAccessToken:testRefreshToken", 3L, TimeUnit.HOURS); + + memberCommandService.unlinkGoogleAccount(teamLeader.getId()); + + verify(googleOauth).revokeToken("testAccessToken"); + } } diff --git a/src/test/java/com/opus/opus/notice/NoticeFixture.java b/src/test/java/com/opus/opus/notice/NoticeFixture.java index a65255df..d3ab007c 100644 --- a/src/test/java/com/opus/opus/notice/NoticeFixture.java +++ b/src/test/java/com/opus/opus/notice/NoticeFixture.java @@ -4,10 +4,19 @@ public class NoticeFixture { - public static Notice createNotice() { + public static Notice createGlobalNotice() { return Notice.builder() - .title("공지 제목입니다.") - .description("공지 내용입니다.") + .title("전체 공지 제목입니다.") + .contestId(null) + .description("전체 공지 내용입니다.") + .build(); + } + + public static Notice createContestNotice(final Long contestId) { + return Notice.builder() + .title("대회 공지 제목입니다.") + .contestId(contestId) + .description("대회 공지 내용입니다.") .build(); } } diff --git a/src/test/java/com/opus/opus/notice/application/NoticeCommandServiceTest.java b/src/test/java/com/opus/opus/notice/application/NoticeCommandServiceTest.java index 69a0c208..f404d677 100644 --- a/src/test/java/com/opus/opus/notice/application/NoticeCommandServiceTest.java +++ b/src/test/java/com/opus/opus/notice/application/NoticeCommandServiceTest.java @@ -2,7 +2,13 @@ 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.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.notice.application.NoticeCommandService; import com.opus.opus.modules.notice.application.dto.request.NoticeRequest; import com.opus.opus.modules.notice.domain.Notice; @@ -20,22 +26,32 @@ public class NoticeCommandServiceTest extends IntegrationTest { @Autowired private NoticeRepository noticeRepository; + @Autowired + private ContestRepository contestRepository; + @Autowired + private ContestCategoryRepository contestCategoryRepository; - private Notice notice; + private Contest contest; + private ContestCategory contestCategory; + private Notice globalNotice; + private Notice contestNotice; @BeforeEach void setUp() { - notice = noticeRepository.save(NoticeFixture.createNotice()); + contestCategory = contestCategoryRepository.save(ContestCategoryFixture.createContestCategory()); + contest = contestRepository.save(ContestFixture.createContestWithCategoryId(contestCategory.getId())); + globalNotice = noticeRepository.save(NoticeFixture.createGlobalNotice()); + contestNotice = noticeRepository.save(NoticeFixture.createContestNotice(contest.getId())); } @Test @DisplayName("[성공] 전체 공지사항이 정상적으로 생성된다.") void 전체_공지사항이_정상적으로_생성된다() { - final NoticeRequest request = new NoticeRequest("공지 제목", "공지 내용"); + final NoticeRequest request = new NoticeRequest("전체 공지 제목", "전체 공지 내용"); noticeCommandService.createNotice(request); - final Notice notice = noticeRepository.findAllByOrderByCreatedAtDesc().get(0); + final Notice notice = noticeRepository.findAllByContestIdIsNullOrderByCreatedAtDesc().get(0); assertThat(notice.getTitle()).isEqualTo(request.title()); assertThat(notice.getDescription()).isEqualTo(request.description()); assertThat(notice.getContestId()).isNull(); @@ -44,13 +60,13 @@ void setUp() { @Test @DisplayName("[성공] 전체 공지사항이 정상적으로 수정된다.") void 전체_공지사항이_정상적으로_수정된다() { - final String beforeTitle = notice.getTitle(); - final String beforeDescription = notice.getDescription(); - final NoticeRequest request = new NoticeRequest("수정 공지 제목", "수정 공지 내용"); + final String beforeTitle = globalNotice.getTitle(); + final String beforeDescription = globalNotice.getDescription(); + final NoticeRequest request = new NoticeRequest("수정 전체 공지 제목", "수정 전체 공지 내용"); - noticeCommandService.updateNotice(request, notice.getId()); + noticeCommandService.updateNotice(request, globalNotice.getId()); - final Notice updateNotice = noticeRepository.findAllByOrderByCreatedAtDesc().get(0); + final Notice updateNotice = noticeRepository.findAllByContestIdIsNullOrderByCreatedAtDesc().get(0); assertThat(updateNotice.getTitle()).isNotEqualTo(beforeTitle); assertThat(updateNotice.getDescription()).isNotEqualTo(beforeDescription); assertThat(updateNotice.getTitle()).isEqualTo(request.title()); @@ -60,10 +76,50 @@ void setUp() { @Test @DisplayName("[성공] 전체 공지사항이 정상적으로 삭제된다.") void 전체_공지사항이_정상적으로_삭제된다() { + assertThat(noticeRepository.count()).isEqualTo(2); + + noticeCommandService.deleteNotice(globalNotice.getId()); + assertThat(noticeRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("[성공] 대회별 공지사항이 정상적으로 생성된다.") + void 대회별_공지사항이_정상적으로_생성된다() { + final NoticeRequest request = new NoticeRequest("대회별 공지 제목", "대회별 공지 내용"); + + noticeCommandService.createContestNotice(contestNotice.getContestId(), request); + + final Notice notice = noticeRepository.findAllByContestIdOrderByCreatedAtDesc(contestNotice.getContestId()) + .get(0); + assertThat(notice.getTitle()).isEqualTo(request.title()); + assertThat(notice.getDescription()).isEqualTo(request.description()); + } - noticeCommandService.deleteNotice(notice.getId()); + @Test + @DisplayName("[성공] 대회별 공지사항이 정상적으로 수정된다.") + void 대회별_공지사항이_정상적으로_수정된다() { + final String beforeTitle = contestNotice.getTitle(); + final String beforeDescription = contestNotice.getDescription(); + final NoticeRequest request = new NoticeRequest("수정 대회별 공지 제목", "수정 대회별 공지 내용"); + + noticeCommandService.updateContestNotice(request, contestNotice.getContestId(), contestNotice.getId()); + + final Notice updateNotice = noticeRepository.findAllByContestIdOrderByCreatedAtDesc( + contestNotice.getContestId()).get(0); + assertThat(updateNotice.getTitle()).isNotEqualTo(beforeTitle); + assertThat(updateNotice.getDescription()).isNotEqualTo(beforeDescription); + assertThat(updateNotice.getTitle()).isEqualTo(request.title()); + assertThat(updateNotice.getDescription()).isEqualTo(request.description()); + } + + @Test + @DisplayName("[성공] 대회별 공지사항이 정상적으로 삭제된다.") + void 대회별_공지사항이_정상적으로_삭제된다() { + assertThat(noticeRepository.count()).isEqualTo(2); + + noticeCommandService.deleteContestNotice(contestNotice.getContestId(), contestNotice.getId()); - assertThat(noticeRepository.count()).isEqualTo(0); + assertThat(noticeRepository.count()).isEqualTo(1); } } diff --git a/src/test/java/com/opus/opus/notice/application/NoticeQueryServiceTest.java b/src/test/java/com/opus/opus/notice/application/NoticeQueryServiceTest.java index d59bcb0d..1ca04182 100644 --- a/src/test/java/com/opus/opus/notice/application/NoticeQueryServiceTest.java +++ b/src/test/java/com/opus/opus/notice/application/NoticeQueryServiceTest.java @@ -2,7 +2,13 @@ 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.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.notice.application.NoticeQueryService; import com.opus.opus.modules.notice.application.dto.response.NoticeDetailResponse; import com.opus.opus.modules.notice.application.dto.response.NoticeSummaryResponse; @@ -22,33 +28,67 @@ public class NoticeQueryServiceTest extends IntegrationTest { @Autowired private NoticeRepository noticeRepository; + @Autowired + private ContestRepository contestRepository; + @Autowired + private ContestCategoryRepository contestCategoryRepository; - private Notice notice; + private Contest contest; + private ContestCategory contestCategory; + private Notice globalNotice; + private Notice contestNotice; @BeforeEach void setUp() { - notice = noticeRepository.save(NoticeFixture.createNotice()); + contestCategory = contestCategoryRepository.save(ContestCategoryFixture.createContestCategory()); + contest = contestRepository.save(ContestFixture.createContestWithCategoryId(contestCategory.getId())); + globalNotice = noticeRepository.save(NoticeFixture.createGlobalNotice()); + contestNotice = noticeRepository.save(NoticeFixture.createContestNotice(contest.getId())); } @Test @DisplayName("[성공] 전체 공지사항을 상세 조회할 수 있다.") void 전체_공지사항을_상세_조회할_수_있다() { - final NoticeDetailResponse response = noticeQueryService.getNotice(notice.getId()); + final NoticeDetailResponse response = noticeQueryService.getNotice(globalNotice.getId()); - assertThat(response.title()).isEqualTo(notice.getTitle()); - assertThat(response.description()).isEqualTo(notice.getDescription()); + assertThat(response.title()).isEqualTo(globalNotice.getTitle()); + assertThat(response.description()).isEqualTo(globalNotice.getDescription()); } @Test @DisplayName("[성공] 전체 공지사항 목록을 조회할 수 있다.") void 전체_공지사항_목록을_조회할_수_있다() { - final Notice anotherNotice = noticeRepository.save(NoticeFixture.createNotice()); + final Notice anotherNotice = noticeRepository.save(NoticeFixture.createGlobalNotice()); final List responses = noticeQueryService.getAllNotices(); assertThat(responses).hasSize(2); assertThat(responses) .extracting(NoticeSummaryResponse::noticeId) - .containsExactlyInAnyOrder(notice.getId(), anotherNotice.getId()); + .containsExactlyInAnyOrder(globalNotice.getId(), anotherNotice.getId()); + } + + @Test + @DisplayName("[성공] 대회별 공지사항을 상세 조회할 수 있다.") + void 대회별_공지사항을_상세_조회할_수_있다() { + final NoticeDetailResponse response = noticeQueryService.getContestNotice(contestNotice.getContestId(), + contestNotice.getId()); + + assertThat(response.title()).isEqualTo(contestNotice.getTitle()); + assertThat(response.description()).isEqualTo(contestNotice.getDescription()); + } + + @Test + @DisplayName("[성공] 대회별 공지사항 목록을 조회할 수 있다.") + void 대회별_공지사항_목록을_조회할_수_있다() { + final Notice anotherContestNotice = noticeRepository.save(NoticeFixture.createContestNotice(contest.getId())); + + final List responses = noticeQueryService.getAllContestNotices( + anotherContestNotice.getContestId()); + + assertThat(responses).hasSize(2); + assertThat(responses) + .extracting(NoticeSummaryResponse::noticeId) + .containsExactlyInAnyOrder(contestNotice.getId(), anotherContestNotice.getId()); } } diff --git a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java index 9452db4e..9ff94f02 100644 --- a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java @@ -5,14 +5,28 @@ import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import com.opus.opus.global.security.JwtProvider; +import com.opus.opus.global.security.annotation.MemberArgumentResolver; 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.contest.application.ContestTeamTemplateCommandService; +import com.opus.opus.modules.contest.application.ContestTeamTemplateQueryService; import com.opus.opus.modules.member.api.MemberController; import com.opus.opus.modules.member.application.MemberCommandService; import com.opus.opus.modules.member.application.MemberQueryService; import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.api.TeamCommentController; +import com.opus.opus.modules.team.application.TeamCommentCommandService; +import com.opus.opus.modules.team.application.TeamCommentQueryService; import com.opus.opus.modules.notice.api.NoticeController; import com.opus.opus.modules.notice.application.NoticeCommandService; import com.opus.opus.modules.notice.application.NoticeQueryService; +import com.opus.opus.modules.team.api.TeamController; +import com.opus.opus.modules.team.application.TeamCommandService; +import com.opus.opus.modules.team.application.TeamQueryService; +import com.opus.opus.modules.team.api.TeamMemberController; +import com.opus.opus.modules.team.application.TeamMemberCommandService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +43,11 @@ @WebMvcTest({ MemberController.class, - NoticeController.class + NoticeController.class, + TeamController.class, + TeamMemberController.class, + ContestController.class, + TeamCommentController.class, }) @Import(RestDocsConfig.class) @ExtendWith(RestDocumentationExtension.class) @@ -42,12 +60,39 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected MemberQueryService memberQueryService; + @MockitoBean + protected TeamCommentCommandService teamCommentCommandService; + + @MockitoBean + protected TeamCommentQueryService teamCommentQueryService; + + @MockitoBean + protected TeamMemberCommandService teamMemberCommandService; + @MockitoBean protected NoticeCommandService noticeCommandService; @MockitoBean protected NoticeQueryService noticeQueryService; + @MockitoBean + protected TeamCommandService teamCommandService; + + @MockitoBean + protected TeamQueryService teamQueryService; + + @MockitoBean + protected ContestCommandService contestCommandService; + + @MockitoBean + protected ContestQueryService contestQueryService; + + @MockitoBean + protected ContestTeamTemplateCommandService contestTeamTemplateCommandService; + + @MockitoBean + protected ContestTeamTemplateQueryService contestTeamTemplateQueryService; + // Setting @Autowired protected WebApplicationContext context; @@ -58,6 +103,9 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected MemberRepository memberRepository; + @MockitoBean + protected MemberArgumentResolver memberArgumentResolver; + @BeforeEach void setUp(final RestDocumentationContextProvider restDocumentation) { this.mockMvc = MockMvcBuilders.webAppContextSetup(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 00000000..f4382dc1 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -0,0 +1,211 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.doNothing; +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.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.put; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +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.ContestVotesLimitResponse; +import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; +import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.restdocs.RestDocsTest; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +public class ContestApiDocsTest extends RestDocsTest { + + private static final String ADMIN_TOKEN = "Bearer admin.access.token"; + + @Test + @DisplayName("[성공] 투표 기간을 조회하면 시작일과 종료일을 반환한다.") + void 투표_기간을_조회하면_시작일과_종료일을_반환한다() throws Exception { + VotePeriodResponse response = new VotePeriodResponse( + LocalDateTime.of(2026, 1, 1, 0, 0, 0), + LocalDateTime.of(2026, 1, 2, 0, 0, 0) + ); + + given(contestQueryService.getVotePeriod(any())).willReturn(response); + + mockMvc.perform(get("/contests/{contestId}/vote", 1L)) + .andExpect(status().isOk()) + .andDo(document("get-vote-period", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + responseFields( + dateTimeFieldWithPath("voteStartAt", "투표 시작일"), + dateTimeFieldWithPath("voteEndAt", "투표 종료일") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 최대 투표 개수가 정상적으로 설정된다.") + void 유효한_요청이면_최대_투표_개수가_정상적으로_설정된다() throws Exception { + final ContestVotesLimitRequest request = new ContestVotesLimitRequest(2); + + doNothing().when(contestCommandService).updateMaxVotesLimit(any(), any()); + + mockMvc.perform(patch("/contests/{contestId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andDo(document("update-max-votes-limit", + pathParameters( + parameterWithName("contestId").description("대회의 고유 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + numberFieldWithPath("maxVotesLimit", "최대 투표 개수") + ) + )); + } + + @Test + @DisplayName("[성공] 관리자는 투표 기간을 수정할 수 있다.") + void 관리자는_투표_기간을_수정할_수_있다() throws Exception { + VoteUpdateRequest request = new VoteUpdateRequest( + LocalDateTime.of(2026, 2, 1, 0, 0, 0), + LocalDateTime.of(2026, 2, 10, 0, 0, 0) + ); + + doNothing().when(contestCommandService).updateVotePeriod(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/vote", 1L) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andDo(document("update-vote-period", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + dateTimeFieldWithPath("voteStartAt", "투표 시작일"), + dateTimeFieldWithPath("voteEndAt", "투표 종료일") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 최대 투표 개수 설정 시 404 에러를 반환한다.") + void 존재하지_않는_대회의_최대_투표_개수_설정_시_에러를_반환한다() throws Exception { + final ContestVotesLimitRequest request = new ContestVotesLimitRequest(2); + + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(contestCommandService) + .updateMaxVotesLimit(any(), any()); + + mockMvc.perform(patch("/contests/{contestId}/votes", 999) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andDo(document("update-max-votes-limit-fail-not-found", + pathParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + numberFieldWithPath("maxVotesLimit", "최대 투표 개수") + ) + )); + } + + @Test + @DisplayName("[실패] 투표 진행 중 최대 투표 개수 변경 시 400 에러를 반환한다.") + void 투표_진행_중_최대_투표_개수_변경_시_에러를_반환한다() throws Exception { + final ContestVotesLimitRequest request = new ContestVotesLimitRequest(2); + + willThrow(new ContestException(CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD)) + .given(contestCommandService) + .updateMaxVotesLimit(any(), any()); + + mockMvc.perform(patch("/contests/{contestId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("update-max-votes-limit-fail-voting-period", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + numberFieldWithPath("maxVotesLimit", "최대 투표 개수") + ) + )); + } + + @Test + @DisplayName("[성공] 최대 투표 개수를 정상적으로 조회할 수 있다.") + void 최대_투표_개수를_정상적으로_조회할_수_있다() throws Exception { + final ContestVotesLimitResponse response = new ContestVotesLimitResponse(2); + + when(contestQueryService.getMaxVotesLimit(any())).thenReturn(response); + + mockMvc.perform(get("/contests/{contestId}/votes", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("get-max-votes-limit", + pathParameters( + parameterWithName("contestId").description("대회의 고유 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + responseFields( + numberFieldWithPath("maxVotesLimit", "최대 투표 개수") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 최대 투표 개수 조회 시 404 에러를 반환한다.") + void 존재하지_않는_대회의_최대_투표_개수_조회_시_에러를_반환한다() throws Exception { + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(contestQueryService) + .getMaxVotesLimit(any()); + + mockMvc.perform(get("/contests/{contestId}/votes", 999) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("get-max-votes-limit-fail-not-found", + pathParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/restdocs/docs/MemberApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/MemberApiDocsTest.java index 686762d0..6987872d 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/MemberApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/MemberApiDocsTest.java @@ -3,6 +3,9 @@ import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_MATCH_EMAIL_AUTH_CODE; import static com.opus.opus.modules.member.exception.MemberExceptionType.CANNOT_VERIFY_EXPIRED_EMAIL_AUTH_CODE; import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_PUSAN_UNIVERSITY_EMAIL; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.OAUTH_AUTHORIZATION_FAILED; +import static com.opus.opus.global.util.oauth.exception.OAuthExceptionType.USER_DENIED_AUTHORIZATION; +import com.opus.opus.global.util.oauth.exception.OAuthException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.doNothing; @@ -15,6 +18,7 @@ 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.restdocs.request.RequestDocumentation.queryParameters; import static org.springframework.test.util.ReflectionTestUtils.setField; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -262,4 +266,79 @@ void setUp() { ) )); } + + @Test + @DisplayName("[성공] Google OAuth 리다이렉트 URL을 반환한다.") + void Google_OAuth_리다이렉트_URL을_반환한다() throws Exception { + String redirectURL = "https://accounts.google.com/o/oauth2/v2/auth?client_id=test&redirect_uri=http://localhost:8080/oauth/google/callback&response_type=code&scope=email+profile&state=test-state"; + + when(memberCommandService.getGoogleOAuthRedirectURL()).thenReturn(redirectURL); + + mockMvc.perform(get("/oauth/google")) + .andExpect(status().isFound()) + .andDo(document("oauth-google-redirect")); + } + + @Test + @DisplayName("[성공] Google OAuth 콜백으로 로그인 처리된다.") + void Google_OAuth_콜백으로_로그인_처리된다() throws Exception { + SignInResponse response = new SignInResponse(member.getId(), member.getName(), "exampleToken", member.getRoles()); + + when(memberCommandService.getGoogleOAuthCallback(any(), any(), any())).thenReturn(response); + + mockMvc.perform(get("/oauth/google/callback") + .param("code", "authorization_code") + .param("state", "state_token")) + .andExpect(status().isOk()) + .andDo(document("oauth-google-callback", + queryParameters( + parameterWithName("code").description("Google 인가 코드"), + parameterWithName("state").description("CSRF 방어용 상태 토큰") + ), + responseFields( + numberFieldWithPath("memberId", "회원 고유 식별자"), + stringFieldWithPath("name", "회원 이름"), + stringFieldWithPath("token", "JWT 액세스 토큰"), + arrayFieldWithPath("types", "회원 권한 목록(회원, 관리자)") + ) + )); + } + + @Test + @DisplayName("[실패] Google OAuth 콜백에서 state가 유효하지 않으면 401 에러를 반환한다.") + void Google_OAuth_콜백에서_state가_유효하지_않으면_에러를_반환한다() throws Exception { + willThrow(new OAuthException(OAUTH_AUTHORIZATION_FAILED)) + .given(memberCommandService).getGoogleOAuthCallback(any(), any(), any()); + + mockMvc.perform(get("/oauth/google/callback") + .param("code", "authorization_code") + .param("state", "invalid_state")) + .andExpect(status().isUnauthorized()) + .andDo(document("oauth-google-callback-fail-state", + queryParameters( + parameterWithName("code").description("Google 인가 코드"), + parameterWithName("state").description("유효하지 않은 상태 토큰") + ) + )); + } + + @Test + @DisplayName("[실패] Google OAuth 콜백에서 사용자가 권한을 거부하면 400 에러를 반환한다.") + void Google_OAuth_콜백에서_사용자가_권한을_거부하면_에러를_반환한다() throws Exception { + willThrow(new OAuthException(USER_DENIED_AUTHORIZATION)) + .given(memberCommandService).getGoogleOAuthCallback(any(), any(), any()); + + mockMvc.perform(get("/oauth/google/callback") + .param("code", "authorization_code") + .param("state", "state_token") + .param("error", "access_denied")) + .andExpect(status().isBadRequest()) + .andDo(document("oauth-google-callback-fail-denied", + queryParameters( + parameterWithName("code").description("Google 인가 코드"), + parameterWithName("state").description("상태 토큰"), + parameterWithName("error").description("에러 코드 (사용자 권한 거부)") + ) + )); + } } diff --git a/src/test/java/com/opus/opus/restdocs/docs/NoticeApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/NoticeApiDocsTest.java index e5e9276f..f8a7c484 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/NoticeApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/NoticeApiDocsTest.java @@ -34,24 +34,23 @@ public class NoticeApiDocsTest extends RestDocsTest { private Member admin; - private String adminToken; + private static final String ADMIN_TOKEN = "Bearer admin.access.token"; @BeforeEach void setUp() { this.admin = MemberFixture.createMember(); setField(admin, "id", 1L); - adminToken = "mock_admin_access_token"; } @Test @DisplayName("[성공] 유효한 요청이면 정상적으로 전체 공지사항이 생성된다.") void 유효한_요청이면_정상적으로_전체_공지사항이_생성된다() throws Exception { - final NoticeRequest request = new NoticeRequest("공지 제목", "공지 내용"); + final NoticeRequest request = new NoticeRequest("전체 공지 제목", "전체 공지 내용"); doNothing().when(noticeCommandService).createNotice(any()); mockMvc.perform(post("/notices") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) @@ -69,12 +68,12 @@ void setUp() { @Test @DisplayName("[성공] 유효한 요청이면 정상적으로 전체 공지사항이 수정된다.") void 유효한_요청이면_정상적으로_전체_공지사항이_수정된다() throws Exception { - final NoticeRequest request = new NoticeRequest("수정된 공지 제목", "수정된 공지 내용"); + final NoticeRequest request = new NoticeRequest("수정된 전체 공지 제목", "수정된 전체 공지 내용"); doNothing().when(noticeCommandService).updateNotice(any(), any()); mockMvc.perform(patch("/notices/{noticeId}", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isNoContent()) @@ -98,7 +97,7 @@ void setUp() { doNothing().when(noticeCommandService).deleteNotice(any()); mockMvc.perform(delete("/notices/{noticeId}", 1) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + adminToken)) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) .andExpect(status().isNoContent()) .andDo(document("delete-notice", pathParameters( @@ -113,11 +112,11 @@ void setUp() { @Test @DisplayName("[성공] 유효한 요청이면 정상적으로 전체 공지사항 상세 조회를 할 수 있다.") void 유효한_요청이면_정상적으로_전체_공지사항_상세_조회를_할_수_있다() throws Exception { - final NoticeDetailResponse response = new NoticeDetailResponse("공지 제목", "공지 내용", now(), now()); + final NoticeDetailResponse response = new NoticeDetailResponse("전체 공지 제목", "전체 공지 내용", now(), now()); when(noticeQueryService.getNotice(any())).thenReturn(response); - mockMvc.perform(get("/notices/{noticeId}", 1L)) + mockMvc.perform(get("/notices/{noticeId}", 1)) .andExpect(status().isOk()) .andDo(document("get-notice", pathParameters( @@ -136,8 +135,8 @@ void setUp() { @DisplayName("[성공] 유효한 요청이면 정상적으로 전체 공지사항 목록을 조회할 수 있다.") void 유효한_요청이면_정상적으로_전체_공지사항_목록을_조회할_수_있다() throws Exception { final List responses = List.of( - new NoticeSummaryResponse(1L, "공지 제목 1", now()), - new NoticeSummaryResponse(2L, "공지 제목 2", now()) + new NoticeSummaryResponse(1L, "전체 공지 제목 1", now()), + new NoticeSummaryResponse(2L, "전체 공지 제목 2", now()) ); when(noticeQueryService.getAllNotices()).thenReturn(responses); @@ -153,4 +152,124 @@ void setUp() { ) )); } + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 대회별 공지사항이 생성된다.") + void 유효한_요청이면_정상적으로_대회별_공지사항이_생성된다() throws Exception { + final NoticeRequest request = new NoticeRequest("공지 제목", "공지 내용"); + + doNothing().when(noticeCommandService).createContestNotice(any(), any()); + + mockMvc.perform(post("/contests/{contestId}/notices", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andDo(document("create-contest-notice", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + stringFieldWithPath("title", "공지 제목"), + stringFieldWithPath("description", "공지 내용") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 대회별 공지사항이 수정된다.") + void 유효한_요청이면_정상적으로_대회별_공지사항이_수정된다() throws Exception { + final NoticeRequest request = new NoticeRequest("수정된 대회별 공지 제목", "수정된 대회별 공지 내용"); + + doNothing().when(noticeCommandService).updateContestNotice(any(), any(), any()); + + mockMvc.perform(patch("/contests/{contestId}/notices/{noticeId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andDo(document("update-contest-notice", + pathParameters( + parameterWithName("contestId").description("대회 ID"), + parameterWithName("noticeId").description("공지 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + stringFieldWithPath("title", "수정된 대회별 공지 제목"), + stringFieldWithPath("description", "수정된 대회별 공지 내용") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 대회별 공지사항이 삭제된다.") + void 유효한_요청이면_정상적으로_대회별_공지사항이_삭제된다() throws Exception { + doNothing().when(noticeCommandService).deleteContestNotice(any(), any()); + + mockMvc.perform(delete("/contests/{contestId}/notices/{noticeId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNoContent()) + .andDo(document("delete-contest-notice", + pathParameters( + parameterWithName("contestId").description("대회 ID"), + parameterWithName("noticeId").description("공지 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 대회별 공지사항 상세 조회를 할 수 있다.") + void 유효한_요청이면_정상적으로_대회별_공지사항_상세_조회를_할_수_있다() throws Exception { + final NoticeDetailResponse response = new NoticeDetailResponse("대회별 공지 제목", "대회별 공지 내용", now(), now()); + + when(noticeQueryService.getContestNotice(any(), any())).thenReturn(response); + + mockMvc.perform(get("/contests/{contestId}/notices/{noticeId}", 1, 1)) + .andExpect(status().isOk()) + .andDo(document("get-contest-notice", + pathParameters( + parameterWithName("contestId").description("대회 ID"), + parameterWithName("noticeId").description("공지 ID") + ), + responseFields( + stringFieldWithPath("title", "공지 제목"), + stringFieldWithPath("description", "공지 내용"), + dateTimeFieldWithPath("createdAt", "공지 생성 시각"), + dateTimeFieldWithPath("updatedAt", "공지 수정 시각") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 대회별 공지사항 목록을 조회할 수 있다.") + void 유효한_요청이면_정상적으로_대회별_공지사항_목록을_조회할_수_있다() throws Exception { + final List responses = List.of( + new NoticeSummaryResponse(1L, "대회별 공지 제목 1", now()), + new NoticeSummaryResponse(2L, "대회별 공지 제목 2", now()) + ); + + when(noticeQueryService.getAllContestNotices(any())).thenReturn(responses); + + mockMvc.perform(get("/contests/{contestId}/notices", 1)) + .andExpect(status().isOk()) + .andDo(document("get-all-contest-notices", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + responseFields( + arrayFieldWithPath("[]", "공지 목록"), + numberFieldWithPath("[].noticeId", "공지 ID"), + stringFieldWithPath("[].title", "공지 제목"), + dateTimeFieldWithPath("[].createdAt", "공지 생성 시각") + ) + )); + } } diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java new file mode 100644 index 00000000..5bf192a0 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java @@ -0,0 +1,113 @@ +package com.opus.opus.restdocs.docs; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +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.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +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.team.application.dto.ImageResponse; +import com.opus.opus.restdocs.RestDocsTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; + +public class TeamApiDocsTest extends RestDocsTest { + + private String accessToken; + private byte[] testImage; + + @BeforeEach + void setUp() { + accessToken = "Bearer member.access.token"; + testImage = "test-image-content".getBytes(); + } + + @Test + @DisplayName("[성공] 팀의 포스터 이미지를 조회한다.") + void 팀의_포스터_이미지를_조회한다() throws Exception { + // Given + final Long teamId = 1L; + final ImageResponse response = new ImageResponse(new ByteArrayResource(testImage), "image/png"); + + when(teamQueryService.getPosterImage(any())).thenReturn(response); + + // When & Then + mockMvc.perform(get("/teams/{teamId}/image/posters", teamId)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.IMAGE_PNG)) + .andDo(document("get-team-poster", + pathParameters( + parameterWithName("teamId").description("팀 ID") + ) + )); + } + + @Test + @DisplayName("[성공] 팀의 포스터 이미지를 등록한다.") + void 팀의_포스터_이미지를_등록한다() throws Exception { + // Given + final Long teamId = 1L; + final MockMultipartFile image = new MockMultipartFile( + "image", + "poster.png", + MediaType.IMAGE_PNG_VALUE, + testImage + ); + + doNothing().when(teamCommandService).savePosterImage(any(), any()); + + // When & Then + mockMvc.perform(multipart("/teams/{teamId}/image/posters", teamId) + .file(image) + .header(HttpHeaders.AUTHORIZATION, accessToken) + .contentType(MediaType.MULTIPART_FORM_DATA)) + .andExpect(status().isCreated()) + .andDo(document("save-team-poster", + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (팀장, 관리자, 팀원 권한)") + ), + requestParts( + partWithName("image").description("등록할 포스터 이미지 (모든 이미지 형식 지원)") + ) + )); + } + + @Test + @DisplayName("[성공] 팀의 포스터 이미지를 삭제한다.") + void 팀의_포스터_이미지를_삭제한다() throws Exception { + // Given + final Long teamId = 1L; + doNothing().when(teamCommandService).deletePosterImage(any()); + + // When & Then + mockMvc.perform(delete("/teams/{teamId}/image/posters", teamId) + .header(HttpHeaders.AUTHORIZATION, accessToken)) + .andExpect(status().isNoContent()) + .andDo(document("delete-team-poster", + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (팀장, 관리자, 팀원 권한)") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamCommentApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamCommentApiDocsTest.java new file mode 100644 index 00000000..7bdca638 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamCommentApiDocsTest.java @@ -0,0 +1,225 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.NOT_OWNER_COMMENT; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doNothing; +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.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; +import static org.springframework.test.util.ReflectionTestUtils.setField; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.team.application.dto.request.TeamCommentCreateRequest; +import com.opus.opus.modules.team.application.dto.request.TeamCommentUpdateRequest; +import com.opus.opus.modules.team.application.dto.response.TeamCommentResponse; +import com.opus.opus.modules.team.exception.TeamCommentException; +import com.opus.opus.modules.team.exception.TeamException; +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.http.MediaType; + +public class TeamCommentApiDocsTest 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 TeamCommentCreateRequest request = new TeamCommentCreateRequest("정말 멋진 프로젝트네요!"); + + doNothing().when(teamCommentCommandService).createComment(any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/comments", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andDo(document("create-team-comment", + pathParameters( + parameterWithName("teamId").description("댓글을 등록할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + stringFieldWithPath("description", "댓글 내용") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에 댓글 등록 시 404 에러를 반환한다.") + void 존재하지_않는_팀에_댓글_등록_시_에러를_반환한다() throws Exception { + final TeamCommentCreateRequest request = new TeamCommentCreateRequest("정말 멋진 프로젝트네요!"); + + willThrow(new TeamException(NOT_FOUND_TEAM)) + .given(teamCommentCommandService) + .createComment(any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/comments", 999) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andDo(document("create-team-comment-fail-not-found", + pathParameters( + parameterWithName("teamId").description("존재하지 않는 팀 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + stringFieldWithPath("description", "댓글 내용") + ) + )); + } + + @Test + @DisplayName("[성공] 팀의 댓글 목록을 정상적으로 조회할 수 있다.") + void 팀의_댓글_목록을_정상적으로_조회할_수_있다() throws Exception { + final List responses = List.of( + new TeamCommentResponse(1L, "정말 멋진 프로젝트네요!", 1L, "이옵스", 1L), + new TeamCommentResponse(2L, "고생하셨습니다!", 2L, "김옵스", 1L) + ); + + when(teamCommentQueryService.getComments(any())).thenReturn(responses); + + mockMvc.perform(get("/teams/{teamId}/comments", 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN)) + .andExpect(status().isOk()) + .andDo(document("get-team-comments", + pathParameters( + parameterWithName("teamId").description("댓글을 조회할 팀의 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + responseFields( + arrayFieldWithPath("[]", "댓글 목록"), + numberFieldWithPath("[].commentId", "댓글 ID"), + stringFieldWithPath("[].description", "댓글 내용"), + numberFieldWithPath("[].memberId", "작성자 ID"), + stringFieldWithPath("[].memberName", "작성자 이름"), + numberFieldWithPath("[].teamId", "팀 ID") + ) + )); + } + + @Test + @DisplayName("[성공] 유효한 요청이면 팀 댓글이 정상적으로 수정된다.") + void 유효한_요청이면_팀_댓글이_정상적으로_수정된다() throws Exception { + final TeamCommentUpdateRequest request = new TeamCommentUpdateRequest("수정된 댓글 내용입니다."); + + doNothing().when(teamCommentCommandService).updateComment(any(), any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/comments/{commentId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(document("update-team-comment", + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("commentId").description("수정할 댓글 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + stringFieldWithPath("description", "수정할 댓글 내용") + ) + )); + } + + @Test + @DisplayName("[실패] 본인이 작성하지 않은 댓글 수정 시 403 에러를 반환한다.") + void 본인이_작성하지_않은_댓글_수정_시_에러를_반환한다() throws Exception { + final TeamCommentUpdateRequest request = new TeamCommentUpdateRequest("수정된 댓글 내용입니다."); + + willThrow(new TeamCommentException(NOT_OWNER_COMMENT)) + .given(teamCommentCommandService) + .updateComment(any(), any(), any(), any()); + + mockMvc.perform(patch("/teams/{teamId}/comments/{commentId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andDo(document("update-team-comment-fail-not-owner", + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("commentId").description("다른 사람이 작성한 댓글 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ), + requestFields( + stringFieldWithPath("description", "수정할 댓글 내용") + ) + )); + } + + @Test + @DisplayName("[성공] 팀 댓글이 정상적으로 삭제된다.") + void 팀_댓글이_정상적으로_삭제된다() throws Exception { + doNothing().when(teamCommentCommandService).deleteComment(any(), any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/comments/{commentId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN)) + .andExpect(status().isNoContent()) + .andDo(document("delete-team-comment", + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("commentId").description("삭제할 댓글 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ) + )); + } + + @Test + @DisplayName("[실패] 본인이 작성하지 않은 댓글 삭제 시 403 에러를 반환한다.") + void 본인이_작성하지_않은_댓글_삭제_시_에러를_반환한다() throws Exception { + willThrow(new TeamCommentException(NOT_OWNER_COMMENT)) + .given(teamCommentCommandService) + .deleteComment(any(), any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/comments/{commentId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, MEMBER_TOKEN)) + .andExpect(status().isForbidden()) + .andDo(document("delete-team-comment-fail-not-owner", + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("commentId").description("다른 사람이 작성한 댓글 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken}") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamMemberApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamMemberApiDocsTest.java new file mode 100644 index 00000000..26543594 --- /dev/null +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamMemberApiDocsTest.java @@ -0,0 +1,305 @@ +package com.opus.opus.restdocs.docs; + +import static com.opus.opus.modules.member.exception.MemberExceptionType.MISMATCH_STUDENT_ID_AND_NAME; +import static com.opus.opus.modules.member.exception.MemberExceptionType.NOT_FOUND_MEMBER; +import static com.opus.opus.modules.team.domain.TeamMemberRoleType.ROLE_팀원; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static com.opus.opus.modules.team.exception.TeamMemberExceptionType.TEAM_MEMBER_ALREADY_EXISTS; +import static com.opus.opus.modules.team.exception.TeamMemberExceptionType.TEAM_MEMBER_NOT_FOUND_IN_TEAM; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.doNothing; +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.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +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.member.domain.Member; +import com.opus.opus.modules.member.exception.MemberException; +import com.opus.opus.modules.team.application.dto.request.TeamMemberCreateRequest; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.modules.team.exception.TeamMemberException; +import com.opus.opus.restdocs.RestDocsTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +public class TeamMemberApiDocsTest extends RestDocsTest { + + private static final String ADMIN_TOKEN = "Bearer admin.access.token"; + private Member admin; + + @BeforeEach + void setUp() { + admin = MemberFixture.createMember(); + setField(admin, "id", 1L); + + when(memberArgumentResolver.supportsParameter(any(MethodParameter.class))) + .thenReturn(true); + when(memberArgumentResolver.resolveArgument( + any(MethodParameter.class), + any(ModelAndViewContainer.class), + any(NativeWebRequest.class), + any(WebDataBinderFactory.class) + )).thenReturn(admin); + } + + // 팀원 추가 + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 팀원이 추가된다.") + void 유효한_요청이면_정상적으로_팀원이_추가된다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("이옵스", "202612345", ROLE_팀원); + + doNothing().when(teamMemberCommandService).createTeamMember(any(), any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/members", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andDo(document("add-team-member", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "추가할 팀원 이름"), + stringFieldWithPath("memberStudentId", "추가할 팀원 학번"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + @Test + @DisplayName("[실패] 팀원명이 비어있으면 400 에러를 반환한다.") + void 팀원명이_비어있으면_에러를_반환한다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("", "202612345", ROLE_팀원); + + mockMvc.perform(post("/teams/{teamId}/members", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("add-team-member-fail-empty-name", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "비어있는 팀원명"), + stringFieldWithPath("memberStudentId", "팀원 학번"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + @Test + @DisplayName("[실패] 팀원학번이 비어있으면 400 에러를 반환한다.") + void 팀원학번이_비어있으면_에러를_반환한다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("이옵스", "", ROLE_팀원); + + mockMvc.perform(post("/teams/{teamId}/members", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("add-team-member-fail-empty-student-id", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "팀원명"), + stringFieldWithPath("memberStudentId", "비어있는 팀원 학번"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀 ID인 경우 404 에러를 반환한다.") + void 존재하지_않는_팀_ID인_경우_에러를_반환한다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("이옵스", "202612345", ROLE_팀원); + + willThrow(new TeamException(NOT_FOUND_TEAM)).given(teamMemberCommandService) + .createTeamMember(any(), any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/members", 999) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(request))) + .andExpect(status().isNotFound()) + .andDo(document("add-team-member-fail-team-not-found", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("존재하지 않는 팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "팀원명"), + stringFieldWithPath("memberStudentId", "팀원 학번"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + @Test + @DisplayName("[실패] 팀원명과 팀원학번이 맞지 않으면 400 에러를 반환한다.") + void 팀원명과_팀원학번이_맞지_않으면_에러를_반환한다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("이옵스", "202612345", ROLE_팀원); + + willThrow(new MemberException(MISMATCH_STUDENT_ID_AND_NAME)).given(teamMemberCommandService) + .createTeamMember(any(), any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/members", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(request))) + .andExpect(status().isBadRequest()) + .andDo(document("add-team-member-fail-mismatch", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "팀원명 (학번과 일치하지 않음)"), + stringFieldWithPath("memberStudentId", "팀원 학번 (이름과 일치하지 않음)"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + @Test + @DisplayName("[실패] 동일한 참가자명 + 학번이 해당 팀에 있는 경우 409 에러를 반환한다.") + void 동일한_참가자명과_학번이_해당_팀에_있는_경우_에러를_반환한다() throws Exception { + final TeamMemberCreateRequest request = new TeamMemberCreateRequest("이옵스", "202612345", ROLE_팀원); + + willThrow(new TeamMemberException(TEAM_MEMBER_ALREADY_EXISTS)).given(teamMemberCommandService) + .createTeamMember(any(), any(), any(), any()); + + mockMvc.perform(post("/teams/{teamId}/members", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(asJsonString(request))) + .andExpect(status().isConflict()) + .andDo(document("add-team-member-fail-already-exists", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID") + ), + requestFields( + stringFieldWithPath("memberName", "이미 등록된 팀원명"), + stringFieldWithPath("memberStudentId", "이미 등록된 팀원 학번"), + stringFieldWithPath("roleType", "추가할 팀원의 역할(ROLE_팀장, ROLE_팀원)") + ) + )); + } + + // 팀원 삭제 + + @Test + @DisplayName("[성공] 유효한 요청이면 정상적으로 팀원이 삭제된다.") + void 유효한_요청이면_정상적으로_팀원이_삭제된다() throws Exception { + doNothing().when(teamMemberCommandService).deleteTeamMember(any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/members/{memberId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNoContent()) + .andDo(document("delete-team-member", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("memberId").description("회원 ID") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀 ID인 경우 404 에러를 반환한다.") + void 삭제_존재하지_않는_팀_ID인_경우_에러를_반환한다() throws Exception { + willThrow(new TeamException(NOT_FOUND_TEAM)).given(teamMemberCommandService) + .deleteTeamMember(any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/members/{memberId}", 999, 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("delete-team-member-fail-team-not-found", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("존재하지 않는 팀 ID"), + parameterWithName("memberId").description("회원 ID") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 멤버 ID인 경우 404 에러를 반환한다.") + void 존재하지_않는_멤버_ID인_경우_에러를_반환한다() throws Exception { + willThrow(new MemberException(NOT_FOUND_MEMBER)).given(teamMemberCommandService) + .deleteTeamMember(any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/members/{memberId}", 1, 999) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("delete-team-member-fail-member-not-found", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("memberId").description("존재하지 않는 회원 ID") + ) + )); + } + + @Test + @DisplayName("[실패] 삭제 대상 팀원이 해당 팀에 없을 경우 404 에러를 반환한다.") + void 삭제_대상_팀원이_해당_팀에_없을_경우_에러를_반환한다() throws Exception { + willThrow(new TeamMemberException(TEAM_MEMBER_NOT_FOUND_IN_TEAM)) + .given(teamMemberCommandService) + .deleteTeamMember(any(), any()); + + mockMvc.perform(delete("/teams/{teamId}/members/{memberId}", 1, 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN)) + .andExpect(status().isNotFound()) + .andDo(document("delete-team-member-fail-not-in-team", + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + pathParameters( + parameterWithName("teamId").description("팀 ID"), + parameterWithName("memberId").description("해당 팀에 속하지 않은 회원 ID") + ) + )); + } +} diff --git a/src/test/java/com/opus/opus/team/FileFixture.java b/src/test/java/com/opus/opus/team/FileFixture.java new file mode 100644 index 00000000..78b4a89b --- /dev/null +++ b/src/test/java/com/opus/opus/team/FileFixture.java @@ -0,0 +1,19 @@ +package com.opus.opus.team; + +import static com.opus.opus.modules.file.domain.FileImageType.POSTER; +import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; + +import com.opus.opus.modules.file.domain.File; + +public class FileFixture { + + public static File createTeamPosterFile() { + return File.builder() + .name("poster.jpg") + .filePath("path/to/poster.webp") + .referenceId(1L) + .referenceType(TEAM) + .imageType(POSTER) + .build(); + } +} diff --git a/src/test/java/com/opus/opus/team/TeamCommentFixture.java b/src/test/java/com/opus/opus/team/TeamCommentFixture.java new file mode 100644 index 00000000..fa63a4fc --- /dev/null +++ b/src/test/java/com/opus/opus/team/TeamCommentFixture.java @@ -0,0 +1,15 @@ +package com.opus.opus.team; + +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamComment; + +public class TeamCommentFixture { + + public static TeamComment createTeamComment(final Team team, final Long memberId) { + return TeamComment.builder() + .description("테스트용 댓글입니다.") + .memberId(memberId) + .team(team) + .build(); + } +} 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 00000000..3a5c34d2 --- /dev/null +++ b/src/test/java/com/opus/opus/team/TeamFixture.java @@ -0,0 +1,23 @@ +package com.opus.opus.team; + +import com.opus.opus.modules.team.domain.Team; +import java.util.ArrayList; + +public class TeamFixture { + + public static Team createTeam() { + return Team.builder() + .teamName("팀 옵스") + .projectName("옵스 프로젝트") + .professorName("김교수") + .overview("이 프로젝트는 옵스 프로젝트입니다.") + .githubPath("http://github.com/example") + .productionPath("http://production.example.com") + .youTubePath("http://youtube.com/example") + .contestId(1L) + .trackId(1L) + .itemOrder(1) + .teamMembers(new ArrayList<>()) + .build(); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java new file mode 100644 index 00000000..16580696 --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamCommandServiceTest.java @@ -0,0 +1,123 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.file.domain.FileImageType.POSTER; +import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.springframework.test.util.ReflectionTestUtils.setField; + +import com.opus.opus.global.util.FileStorageUtil; +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.modules.file.domain.File; +import com.opus.opus.modules.file.domain.dao.FileRepository; +import com.opus.opus.modules.team.application.TeamCommandService; +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 com.opus.opus.team.FileFixture; +import com.opus.opus.team.TeamFixture; +import java.util.ArrayList; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class TeamCommandServiceTest extends IntegrationTest { + + @Autowired + private TeamCommandService teamCommandService; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private FileRepository fileRepository; + + private Team team; + + @BeforeEach + void setUp() { + team = teamRepository.save(TeamFixture.createTeam()); + } + + @Test + @DisplayName("[성공] 팀 포스터 이미지를 저장한다.") + void 팀_포스터_이미지를_저장한다() { + // given + final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", + "content".getBytes()); + + // when + teamCommandService.savePosterImage(team.getId(), image); + + // then + verify(fileStorageUtil, times(1)).storeFile(any(), eq(team.getId()), eq(TEAM), eq(POSTER)); + } + + @Test + @DisplayName("[실패] 팀이 존재하지 않으면 포스터 이미지를 저장할 수 없다.") + void 팀이_존재하지_않으면_포스터_이미지를_저장할_수_없다() { + // given + final MockMultipartFile image = new MockMultipartFile("image", "poster.jpg", "image/jpeg", + "content".getBytes()); + final long notExistTeamId = 999L; + + // when & then + assertThatThrownBy(() -> teamCommandService.savePosterImage(notExistTeamId, image)) + .isInstanceOf(TeamException.class) + .hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[성공] 팀 포스터 이미지를 삭제한다.") + void 팀_포스터_이미지를_삭제한다() { + // given + final File file = FileFixture.createTeamPosterFile(); + setField(file, "referenceId", team.getId()); + final File savedFile = fileRepository.save(file); + savedFile.updateIsWebpConverted(true); + fileRepository.saveAndFlush(savedFile); + + // when + teamCommandService.deletePosterImage(team.getId()); + + // then + verify(fileStorageUtil, times(1)).deleteFile(savedFile.getId()); + } + + @Test + @DisplayName("[성공] 팀 포스터 이미지가 없어도 삭제 요청 시 예외가 발생하지 않는다.") + void 팀_포스터_이미지가_없어도_삭제_요청_시_예외가_발생하지_않는다() { + // when + teamCommandService.deletePosterImage(team.getId()); + + // then + verify(fileStorageUtil, never()).deleteFile(any()); + } + + @Test + @DisplayName("[성공] 팀 포스터 이미지가 이미 존재하면 기존 이미지를 삭제하고 새로 저장한다.") + void 팀_포스터_이미지가_이미_존재하면_기존_이미지를_삭제하고_새로_저장한다() { + // given + final File existingFile = FileFixture.createTeamPosterFile(); + setField(existingFile, "referenceId", team.getId()); + final File savedFile = fileRepository.save(existingFile); + + final MockMultipartFile newImage = new MockMultipartFile("image", "new_poster.jpg", "image/jpeg", + "new_content".getBytes()); + + // when + teamCommandService.savePosterImage(team.getId(), newImage); + + // then + verify(fileStorageUtil, times(1)).deleteFile(savedFile.getId()); + verify(fileStorageUtil, times(1)).storeFile(any(), eq(team.getId()), eq(TEAM), eq(POSTER)); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamCommentCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamCommentCommandServiceTest.java new file mode 100644 index 00000000..f209208d --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamCommentCommandServiceTest.java @@ -0,0 +1,174 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.COMMENT_NOT_BELONG_TO_TEAM; +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.NOT_FOUND_COMMENT; +import static com.opus.opus.modules.team.exception.TeamCommentExceptionType.NOT_OWNER_COMMENT; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.application.TeamCommentCommandService; +import com.opus.opus.modules.team.application.dto.request.TeamCommentCreateRequest; +import com.opus.opus.modules.team.application.dto.request.TeamCommentUpdateRequest; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamComment; +import com.opus.opus.modules.team.domain.dao.TeamCommentRepository; +import com.opus.opus.modules.team.domain.dao.TeamRepository; +import com.opus.opus.modules.team.exception.TeamCommentException; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.team.TeamFixture; +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 TeamCommentCommandServiceTest extends IntegrationTest { + + @Autowired + private TeamCommentCommandService teamCommentCommandService; + + @Autowired + private TeamRepository teamRepository; + @Autowired + private MemberRepository memberRepository; + @Autowired + private TeamCommentRepository teamCommentRepository; + + private Team team; + private Member member; + private final String commentDescription = "테스트용 댓글입니다."; + private final String updatedCommentDescription = "수정된 댓글입니다."; + private TeamCommentCreateRequest commentCreateRequest; + + @BeforeEach + void setUp() { + team = teamRepository.save(TeamFixture.createTeam()); + member = memberRepository.save(MemberFixture.createMember()); + commentCreateRequest = new TeamCommentCreateRequest(commentDescription); + } + + @Test + @DisplayName("[성공] 팀 댓글이 정상적으로 등록된다.") + void 팀_댓글이_정상적으로_등록된다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + + final TeamComment savedComment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + assertThat(savedComment.getDescription()).isEqualTo(commentCreateRequest.description()); + assertThat(savedComment.getMemberId()).isEqualTo(member.getId()); + assertThat(savedComment.getTeam().getId()).isEqualTo(team.getId()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀에는 댓글 등록이 불가능하다.") + void 존재하지_않는_팀에는_댓글_등록이_불가능하다() { + final Long invalidTeamId = 999L; + + assertThatThrownBy(() -> { + teamCommentCommandService.createComment(invalidTeamId, member.getId(), commentCreateRequest.description()); + }).isInstanceOf(TeamException.class).hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[성공] 댓글이 정상적으로 수정된다.") + void 댓글이_정상적으로_수정된다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + final TeamCommentUpdateRequest updateRequest = new TeamCommentUpdateRequest(updatedCommentDescription); + + teamCommentCommandService.updateComment(team.getId(), comment.getId(), member.getId(), updateRequest.description()); + + final TeamComment updatedComment = teamCommentRepository.findById(comment.getId()).orElseThrow(); + assertThat(updatedComment.getDescription()).isEqualTo(updateRequest.description()); + assertThat(updatedComment.getDescription()).isNotEqualTo(commentCreateRequest.description()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 댓글은 수정할 수 없다.") + void 존재하지_않는_댓글은_수정할_수_없다() { + final Long invalidCommentId = 999L; + final TeamCommentUpdateRequest request = new TeamCommentUpdateRequest(updatedCommentDescription); + + assertThatThrownBy(() -> { + teamCommentCommandService.updateComment(team.getId(), invalidCommentId, member.getId(), request.description()); + }).isInstanceOf(TeamCommentException.class).hasMessage(NOT_FOUND_COMMENT.errorMessage()); + } + + @Test + @DisplayName("[실패] 본인이 작성하지 않은 댓글은 수정할 수 없다.") + void 본인이_작성하지_않은_댓글은_수정할_수_없다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + final Member otherMember = memberRepository.save(MemberFixture.createMemberWithUniqueNum(1)); + + final TeamCommentUpdateRequest updateRequest = new TeamCommentUpdateRequest(updatedCommentDescription); + + assertThatThrownBy(() -> { + teamCommentCommandService.updateComment(team.getId(), comment.getId(), otherMember.getId(), updateRequest.description()); + }).isInstanceOf(TeamCommentException.class).hasMessage(NOT_OWNER_COMMENT.errorMessage()); + } + + @Test + @DisplayName("[실패] 다른 팀의 댓글은 수정할 수 없다.") + void 다른_팀의_댓글은_수정할_수_없다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + final Team otherTeam = teamRepository.save(TeamFixture.createTeam()); + + final TeamCommentUpdateRequest updateRequest = new TeamCommentUpdateRequest(updatedCommentDescription); + + assertThatThrownBy(() -> { + teamCommentCommandService.updateComment(otherTeam.getId(), comment.getId(), member.getId(), updateRequest.description()); + }).isInstanceOf(TeamCommentException.class) + .hasMessage(COMMENT_NOT_BELONG_TO_TEAM.errorMessage()); + } + + @Test + @DisplayName("[성공] 댓글이 정상적으로 삭제된다.") + void 댓글이_정상적으로_삭제된다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + + teamCommentCommandService.deleteComment(team.getId(), comment.getId(), member.getId()); + + assertThat(teamCommentRepository.findById(comment.getId())).isEmpty(); + } + + @Test + @DisplayName("[실패] 다른 팀의 댓글은 삭제할 수 없다.") + void 다른_팀의_댓글은_삭제할_수_없다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + final Team otherTeam = teamRepository.save(TeamFixture.createTeam()); + + assertThatThrownBy(() -> { + teamCommentCommandService.deleteComment(otherTeam.getId(), comment.getId(), member.getId()); + }).isInstanceOf(TeamCommentException.class) + .hasMessage(COMMENT_NOT_BELONG_TO_TEAM.errorMessage()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 댓글은 삭제할 수 없다.") + void 존재하지_않는_댓글은_삭제할_수_없다() { + final Long invalidCommentId = 999L; + + assertThatThrownBy(() -> { + teamCommentCommandService.deleteComment(team.getId(), invalidCommentId, member.getId()); + }).isInstanceOf(TeamCommentException.class).hasMessage(NOT_FOUND_COMMENT.errorMessage()); + } + + @Test + @DisplayName("[실패] 본인이 작성하지 않은 댓글은 삭제할 수 없다.") + void 본인이_작성하지_않은_댓글은_삭제할_수_없다() { + teamCommentCommandService.createComment(team.getId(), member.getId(), commentCreateRequest.description()); + final TeamComment comment = teamCommentRepository.findAllByTeamIdOrderByIdDesc(team.getId()).get(0); + final Member otherMember = memberRepository.save(MemberFixture.createMemberWithUniqueNum(1)); + + assertThatThrownBy(() -> { + teamCommentCommandService.deleteComment(team.getId(), comment.getId(), otherMember.getId()); + }).isInstanceOf(TeamCommentException.class).hasMessage(NOT_OWNER_COMMENT.errorMessage()); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamCommentQueryServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamCommentQueryServiceTest.java new file mode 100644 index 00000000..f2d3a49b --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamCommentQueryServiceTest.java @@ -0,0 +1,109 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.application.TeamCommentQueryService; +import com.opus.opus.modules.team.application.dto.response.TeamCommentResponse; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamComment; +import com.opus.opus.modules.team.domain.dao.TeamCommentRepository; +import com.opus.opus.modules.team.domain.dao.TeamRepository; +import com.opus.opus.modules.team.exception.TeamException; +import com.opus.opus.team.TeamCommentFixture; +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 TeamCommentQueryServiceTest extends IntegrationTest { + + @Autowired + private TeamCommentQueryService teamCommentQueryService; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TeamCommentRepository teamCommentRepository; + + private Team team; + private Member member; + private final String commentDescription = "테스트용 댓글입니다."; + + @BeforeEach + void setUp() { + team = teamRepository.save(TeamFixture.createTeam()); + member = memberRepository.save(MemberFixture.createMember()); + } + + @Test + @DisplayName("[성공] 팀의 댓글 목록을 조회할 수 있다.") + void 팀의_댓글_목록을_조회할_수_있다() { + teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, member.getId())); + teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, member.getId())); + + final List commentResponseList = teamCommentQueryService.getComments(team.getId()); + + assertThat(commentResponseList).hasSize(2); + assertThat(commentResponseList.get(0).description()).isEqualTo(commentDescription); + assertThat(commentResponseList.get(0).memberId()).isEqualTo(member.getId()); + assertThat(commentResponseList.get(0).memberName()).isEqualTo(member.getName()); + assertThat(commentResponseList.get(0).teamId()).isEqualTo(team.getId()); + } + + @Test + @DisplayName("[성공] 댓글이 없는 팀의 경우 빈 리스트를 반환한다.") + void 댓글이_없는_팀의_경우_빈_리스트를_반환한다() { + final List responses = teamCommentQueryService.getComments(team.getId()); + + assertThat(responses).isEmpty(); + } + + @Test + @DisplayName("[성공] 댓글 목록은 최신순으로 정렬되어 조회된다.") + void 댓글_목록은_최신순으로_정렬되어_조회된다() { + final TeamComment firstComment = teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, member.getId())); + final TeamComment secondComment = teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, member.getId())); + + final List commentResponseList = teamCommentQueryService.getComments(team.getId()); + + assertThat(commentResponseList).hasSize(2); + assertThat(commentResponseList.get(0).commentId()).isEqualTo(secondComment.getId()); + assertThat(commentResponseList.get(1).commentId()).isEqualTo(firstComment.getId()); + } + + @Test + @DisplayName("[성공] 여러 회원이 작성한 댓글을 조회할 수 있다.") + void 여러_회원이_작성한_댓글을_조회할_수_있다() { + final Member otherMember = memberRepository.save(MemberFixture.createMemberWithUniqueNum(1)); + teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, member.getId())); + teamCommentRepository.save(TeamCommentFixture.createTeamComment(team, otherMember.getId())); + + final List commentResponseList = teamCommentQueryService.getComments(team.getId()); + + assertThat(commentResponseList).hasSize(2); + assertThat(commentResponseList).extracting(TeamCommentResponse::memberName) + .containsExactly(otherMember.getName(), member.getName()); + } + + @Test + @DisplayName("[실패] 존재하지 않는 팀의 댓글 목록은 조회할 수 없다.") + void 존재하지_않는_팀의_댓글_목록은_조회할_수_없다() { + final Long invalidTeamId = 999L; + + assertThatThrownBy(() -> { + teamCommentQueryService.getComments(invalidTeamId); + }).isInstanceOf(TeamException.class).hasMessage(NOT_FOUND_TEAM.errorMessage()); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamMemberCommandServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamMemberCommandServiceTest.java new file mode 100644 index 00000000..34b3fbd0 --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamMemberCommandServiceTest.java @@ -0,0 +1,130 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.member.exception.MemberExceptionType.MISMATCH_STUDENT_ID_AND_NAME; +import static com.opus.opus.modules.team.domain.TeamMemberRoleType.ROLE_팀원; +import static com.opus.opus.modules.team.domain.TeamMemberRoleType.ROLE_팀장; +import static com.opus.opus.modules.team.exception.TeamMemberExceptionType.TEAM_MEMBER_NOT_FOUND_IN_TEAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.opus.opus.helper.IntegrationTest; +import com.opus.opus.member.MemberFixture; +import com.opus.opus.modules.member.domain.Member; +import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.member.exception.MemberException; +import com.opus.opus.modules.team.application.TeamMemberCommandService; +import com.opus.opus.modules.team.application.dto.request.TeamMemberCreateRequest; +import com.opus.opus.modules.team.domain.Team; +import com.opus.opus.modules.team.domain.TeamMember; +import com.opus.opus.modules.team.domain.dao.TeamMemberRepository; +import com.opus.opus.modules.team.domain.dao.TeamRepository; +import com.opus.opus.modules.team.exception.TeamMemberException; +import com.opus.opus.team.TeamFixture; +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 TeamMemberCommandServiceTest extends IntegrationTest { + + @Autowired + private TeamMemberCommandService teamMemberCommandService; + + @Autowired + private TeamRepository teamRepository; + @Autowired + private TeamMemberRepository teamMemberRepository; + @Autowired + private MemberRepository memberRepository; + + private Team team; + private Member member; + private TeamMemberCreateRequest request; + + @BeforeEach + void setUp() { + team = teamRepository.save(TeamFixture.createTeam()); + member = memberRepository.save(MemberFixture.createMember()); + request = new TeamMemberCreateRequest("이사람", "123456789", ROLE_팀원); + } + + @Test + @DisplayName("[성공] 회원가입한 학번과 이름이 없으면 가짜 회원을 생성 후 팀원으로 추가한다.") + void 회원가입한_학번과_이름이_없으면_가짜_회원을_생성_후_팀원으로_추가한다() { + final String notExistStudentId = "202654321"; + final String notExistStudentName = "문스옵"; + + teamMemberCommandService.createTeamMember(team.getId(), notExistStudentId, notExistStudentName, + request.roleType()); + + final Member fakeMember = memberRepository.findByStudentId(notExistStudentId).orElseThrow(); + assertThat(fakeMember.getStudentId()).isEqualTo(notExistStudentId); + + final TeamMember teamMember = teamMemberRepository.findByTeamIdAndMemberId(team.getId(), fakeMember.getId()) + .get(); + assertThat(teamMember.getMemberId()).isEqualTo(fakeMember.getId()); + assertThat(teamMember.getTeam().getId()).isEqualTo(team.getId()); + } + + @Test + @DisplayName("[성공] 이미 회원가입한 회원의 학번과 이름으로 팀원으로 추가될 때 기존 회원을 팀원으로 추가한다") + void 이미_회원가입한_회원의_학번과_이름으로_팀원으로_추가될_때_기존_회원을_팀원으로_추가한다() { + final String signedUpStudentId = member.getStudentId(); + final String signedUpStudentName = member.getName(); + + teamMemberCommandService.createTeamMember(team.getId(), signedUpStudentId, signedUpStudentName, + request.roleType()); + + final TeamMember teamMember = teamMemberRepository.findByTeamIdAndMemberId(team.getId(), member.getId()) + .orElseThrow(); + assertThat(teamMember.getMemberId()).isEqualTo(member.getId()); + } + + @Test + @DisplayName("[실패] 이미 회원가입한 회원의 학번인데 저장된 이름과 같지 않으면 팀원으로 추가 불가하다.") + void 이미_회원가입한_회원의_학번인데_저장된_이름과_같지_않으면_팀원으로_추가_불가하다() { + final String studentId = member.getStudentId(); + final String wrongStudentName = "이옵스아님"; + + assertThatThrownBy(() -> { + teamMemberCommandService.createTeamMember(team.getId(), studentId, wrongStudentName, request.roleType()); + }).isInstanceOf(MemberException.class).hasMessage(MISMATCH_STUDENT_ID_AND_NAME.errorMessage()); + } + + @Test + @DisplayName("[성공] roleType이 팀장이라면 팀장으로 저장된다.") + void roleType이_팀장이라면_팀장으로_저장된다() { + final TeamMemberCreateRequest teamLeaderRequest = new TeamMemberCreateRequest("나팀장", "202798743", ROLE_팀장); + + teamMemberCommandService.createTeamMember(team.getId(), teamLeaderRequest.memberStudentId(), + teamLeaderRequest.memberName(), teamLeaderRequest.roleType()); + + final Member addedMember = memberRepository.findByStudentId(teamLeaderRequest.memberStudentId()) + .orElseThrow(); + final TeamMember teamLeader = teamMemberRepository.findByTeamIdAndMemberId(team.getId(), addedMember.getId()) + .orElseThrow(); + assertThat(teamLeader.getRoles()).containsExactly(teamLeaderRequest.roleType()); + } + + @Test + @DisplayName("[성공] 팀원이 정상적으로 삭제된다.") + void 팀원이_정상적으로_삭제된다() { + teamMemberCommandService.createTeamMember(team.getId(), request.memberStudentId(), request.memberName(), + request.roleType()); + final Member addedMember = memberRepository.findByStudentId(request.memberStudentId()).get(); + + teamMemberCommandService.deleteTeamMember(team.getId(), addedMember.getId()); + + assertThat(teamMemberRepository.existsByTeamIdAndMemberId(team.getId(), addedMember.getId())).isFalse(); + } + + @Test + @DisplayName("[실패] 팀원 목록에 팀원 정보가 없다면 팀원 삭제 불가하다.") + void 팀원_목록에_팀원_정보가_없다면_팀원_삭제_불가하다() { + final Team otherTeam = teamRepository.save(TeamFixture.createTeam()); + + assertThatThrownBy(() -> { + teamMemberCommandService.deleteTeamMember(otherTeam.getId(), member.getId()); + }).isInstanceOf(TeamMemberException.class).hasMessage(TEAM_MEMBER_NOT_FOUND_IN_TEAM.errorMessage()); + } +} diff --git a/src/test/java/com/opus/opus/team/application/TeamQueryServiceTest.java b/src/test/java/com/opus/opus/team/application/TeamQueryServiceTest.java new file mode 100644 index 00000000..de1adf7c --- /dev/null +++ b/src/test/java/com/opus/opus/team/application/TeamQueryServiceTest.java @@ -0,0 +1,100 @@ +package com.opus.opus.team.application; + +import static com.opus.opus.modules.file.domain.FileImageType.POSTER; +import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; +import static com.opus.opus.modules.file.exception.FileExceptionType.NOT_EXISTS_MATCHING_IMAGE_ID; +import static com.opus.opus.modules.team.exception.TeamExceptionType.NOT_FOUND_TEAM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +import com.opus.opus.global.util.FileStorageUtil; +import com.opus.opus.helper.IntegrationTest; +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.TeamQueryService; +import com.opus.opus.modules.team.application.dto.ImageResponse; +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 com.opus.opus.team.TeamFixture; +import java.util.ArrayList; +import org.antlr.v4.runtime.misc.Pair; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.Resource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class TeamQueryServiceTest extends IntegrationTest { + + @Autowired + private TeamQueryService teamQueryService; + + @Autowired + private TeamRepository teamRepository; + + @Autowired + private FileRepository fileRepository; + + @Autowired + private FileStorageUtil fileStorageUtil; + + private Team team; + + @BeforeEach + void setUp() { + team = teamRepository.save(TeamFixture.createTeam()); + } + + @Test + @DisplayName("[성공] 팀 포스터 이미지를 조회한다.") + void 팀_포스터_이미지를_조회한다() { + // given + final File file = File.builder() + .referenceId(team.getId()) + .referenceType(TEAM) + .imageType(POSTER) + .filePath("path/to/poster.webp") + .name("poster.jpg") + .build(); + final File savedFile = fileRepository.save(file); + savedFile.updateIsWebpConverted(true); + fileRepository.saveAndFlush(savedFile); + + Resource resource = new ByteArrayResource("content".getBytes()); + given(fileStorageUtil.findFileAndType(savedFile.getId())) + .willReturn(new Pair<>(resource, "image/webp")); + + // when + ImageResponse response = teamQueryService.getPosterImage(team.getId()); + + // then + assertThat(response).isNotNull(); + assertThat(response.contentType()).isEqualTo("image/webp"); + } + + @Test + @DisplayName("[실패] 팀이 존재하지 않으면 포스터 이미지를 조회할 수 없다.") + void 팀이_존재하지_않으면_포스터_이미지를_조회할_수_없다() { + // given + long notExistTeamId = 999L; + + // when & then + assertThatThrownBy(() -> teamQueryService.getPosterImage(notExistTeamId)) + .isInstanceOf(TeamException.class) + .hasMessage(NOT_FOUND_TEAM.errorMessage()); + } + + @Test + @DisplayName("[실패] 팀 포스터 이미지가 존재하지 않으면 조회할 수 없다.") + void 팀_포스터_이미지가_존재하지_않으면_조회할_수_없다() { + // when & then + assertThatThrownBy(() -> teamQueryService.getPosterImage(team.getId())) + .isInstanceOf(FileException.class) + .hasMessage(NOT_EXISTS_MATCHING_IMAGE_ID.errorMessage()); + } +} From e6330844cadcb46501fb8af01c148241e3322609 Mon Sep 17 00:00:00 2001 From: myeowon Date: Wed, 18 Feb 2026 01:42:40 +0900 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20=EA=B8=B0=ED=9A=8D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=9D=BC=20HIDDEN=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=ED=95=84=EC=88=98?= =?UTF-8?q?/=EC=84=A0=ED=83=9D(required=20true/false)=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContestTeamTemplateCommandService.java | 24 +-- .../ContestTeamTemplateConvenience.java | 107 ++++++------ .../dto/request/TeamTemplateRequest.java | 53 +++--- .../dto/response/TeamTemplateResponse.java | 51 +++--- .../contest/domain/ContestTeamTemplate.java | 158 ++++++++---------- .../domain/ContestTeamTemplateFieldType.java | 18 -- 6 files changed, 187 insertions(+), 224 deletions(-) delete mode 100644 src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java index 0c5c84f0..27246ebe 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java @@ -23,18 +23,18 @@ public void updateTeamTemplate(final Long contestId, final TeamTemplateRequest r contestId); template.updateTemplate( - request.division(), - request.projectName(), - request.teamName(), - request.leader(), - request.teamMembers(), - request.professor(), - request.githubPath(), - request.youtubePath(), - request.productionPath(), - request.overview(), - request.poster(), - request.images() + request.divisionRequired(), + request.projectNameRequired(), + request.teamNameRequired(), + request.leaderRequired(), + request.teamMembersRequired(), + request.professorRequired(), + request.githubPathRequired(), + request.youtubePathRequired(), + request.productionPathRequired(), + request.overviewRequired(), + request.posterRequired(), + request.imagesRequired() ); } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java index 2e024e49..b8073c8a 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java @@ -1,13 +1,9 @@ package com.opus.opus.modules.contest.application.convenience; -import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.HIDDEN; -import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.OPTIONAL; -import static com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType.REQUIRED; import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.ContestTeamTemplate; -import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; import com.opus.opus.modules.contest.domain.dao.ContestTeamTemplateRepository; import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; import java.util.HashMap; @@ -29,72 +25,71 @@ public ContestTeamTemplate getValidateExistTemplate(final Long contestId) { } public void createTemplate(final Contest contest, final String categoryName) { - final Map settings = getTemplateDefaultSettings(categoryName); + final Map settings = getTemplateDefaultSettings(categoryName); ContestTeamTemplate template = ContestTeamTemplate.builder() .contest(contest) - .division(settings.get("division")) - .projectName(settings.get("projectName")) - .teamName(settings.get("teamName")) - .leader(settings.get("leader")) - .teamMembers(settings.get("teamMembers")) - .professor(settings.get("professor")) - .githubPath(settings.get("githubPath")) - .youtubePath(settings.get("youtubePath")) - .productionPath(settings.get("productionPath")) - .overview(settings.get("overview")) - .poster(settings.get("poster")) - .images(settings.get("images")) + .divisionRequired(settings.get("division")) + .projectNameRequired(settings.get("projectName")) + .teamNameRequired(settings.get("teamName")) + .leaderRequired(settings.get("leader")) + .teamMembersRequired(settings.get("teamMembers")) + .professorRequired(settings.get("professor")) + .githubPathRequired(settings.get("githubPath")) + .youtubePathRequired(settings.get("youtubePath")) + .productionPathRequired(settings.get("productionPath")) + .overviewRequired(settings.get("overview")) + .posterRequired(settings.get("poster")) + .imagesRequired(settings.get("images")) .build(); contestTeamTemplateRepository.save(template); } - private Map getTemplateDefaultSettings(final String categoryName) { - - Map map = new HashMap<>(); + private Map getTemplateDefaultSettings(final String categoryName) { + Map map = new HashMap<>(); if (categoryName.contains("창의융합")) { - map.put("division", REQUIRED); - map.put("projectName", REQUIRED); - map.put("teamName", REQUIRED); - map.put("leader", REQUIRED); - map.put("teamMembers", REQUIRED); - map.put("professor", HIDDEN); - map.put("githubPath", REQUIRED); - map.put("youtubePath", OPTIONAL); - map.put("productionPath", OPTIONAL); - map.put("overview", REQUIRED); - map.put("poster", REQUIRED); - map.put("images", REQUIRED); + map.put("division", true); + map.put("projectName", true); + map.put("teamName", true); + map.put("leader", true); + map.put("teamMembers", true); + map.put("professor", false); + map.put("githubPath", true); + map.put("youtubePath", false); + map.put("productionPath", false); + map.put("overview", true); + map.put("poster", true); + map.put("images", true); } else if (categoryName.contains("캡스톤")) { - map.put("division", REQUIRED); - map.put("projectName", REQUIRED); - map.put("teamName", REQUIRED); - map.put("leader", REQUIRED); - map.put("teamMembers", REQUIRED); - map.put("professor", REQUIRED); - map.put("githubPath", REQUIRED); - map.put("youtubePath", REQUIRED); - map.put("productionPath", OPTIONAL); - map.put("overview", REQUIRED); - map.put("poster", OPTIONAL); - map.put("images", REQUIRED); + map.put("division", true); + map.put("projectName", true); + map.put("teamName", true); + map.put("leader", true); + map.put("teamMembers", true); + map.put("professor", true); + map.put("githubPath", true); + map.put("youtubePath", true); + map.put("productionPath", false); + map.put("overview", true); + map.put("poster", false); + map.put("images", true); } else { - map.put("division", OPTIONAL); - map.put("projectName", OPTIONAL); - map.put("teamName", OPTIONAL); - map.put("leader", OPTIONAL); - map.put("teamMembers", OPTIONAL); - map.put("professor", OPTIONAL); - map.put("githubPath", OPTIONAL); - map.put("youtubePath", OPTIONAL); - map.put("productionPath", OPTIONAL); - map.put("overview", OPTIONAL); - map.put("poster", OPTIONAL); - map.put("images", OPTIONAL); + map.put("division", false); + map.put("projectName", false); + map.put("teamName", false); + map.put("leader", false); + map.put("teamMembers", false); + map.put("professor", false); + map.put("githubPath", false); + map.put("youtubePath", false); + map.put("productionPath", false); + map.put("overview", false); + map.put("poster", false); + map.put("images", false); } return map; } diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java index 4649c000..17153b71 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java @@ -1,33 +1,36 @@ package com.opus.opus.modules.contest.application.dto.request; -import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; import jakarta.validation.constraints.NotNull; +/** + * 필수 항목 설정 요청 + * 각 항목은 필수 여부만 설정 가능합니다. (true: 필수 입력, false: 선택 입력) + */ public record TeamTemplateRequest( - @NotNull(message = "분과 설정은 필수입니다.") - ContestTeamTemplateFieldType division, - @NotNull(message = "프로젝트명 설정은 필수입니다.") - ContestTeamTemplateFieldType projectName, - @NotNull(message = "팀명 설정은 필수입니다.") - ContestTeamTemplateFieldType teamName, - @NotNull(message = "팀장 설정은 필수입니다.") - ContestTeamTemplateFieldType leader, - @NotNull(message = "팀원 설정은 필수입니다.") - ContestTeamTemplateFieldType teamMembers, - @NotNull(message = "지도 교수 설정은 필수입니다.") - ContestTeamTemplateFieldType professor, - @NotNull(message = "GitHub 링크 설정은 필수입니다.") - ContestTeamTemplateFieldType githubPath, - @NotNull(message = "YouTube 링크 설정은 필수입니다.") - ContestTeamTemplateFieldType youtubePath, - @NotNull(message = "배포 링크 설정은 필수입니다.") - ContestTeamTemplateFieldType productionPath, - @NotNull(message = "프로젝트 개요 설정은 필수입니다.") - ContestTeamTemplateFieldType overview, - @NotNull(message = "포스터 설정은 필수입니다.") - ContestTeamTemplateFieldType poster, - @NotNull(message = "이미지 설정은 필수입니다.") - ContestTeamTemplateFieldType images + @NotNull(message = "분과 필수 여부는 필수입니다.") + Boolean divisionRequired, + @NotNull(message = "프로젝트명 필수 여부는 필수입니다.") + Boolean projectNameRequired, + @NotNull(message = "팀명 필수 여부는 필수입니다.") + Boolean teamNameRequired, + @NotNull(message = "팀장 필수 여부는 필수입니다.") + Boolean leaderRequired, + @NotNull(message = "팀원 필수 여부는 필수입니다.") + Boolean teamMembersRequired, + @NotNull(message = "지도 교수 필수 여부는 필수입니다.") + Boolean professorRequired, + @NotNull(message = "GitHub 링크 필수 여부는 필수입니다.") + Boolean githubPathRequired, + @NotNull(message = "YouTube 링크 필수 여부는 필수입니다.") + Boolean youtubePathRequired, + @NotNull(message = "배포 링크 필수 여부는 필수입니다.") + Boolean productionPathRequired, + @NotNull(message = "프로젝트 개요 필수 여부는 필수입니다.") + Boolean overviewRequired, + @NotNull(message = "포스터 필수 여부는 필수입니다.") + Boolean posterRequired, + @NotNull(message = "이미지 필수 여부는 필수입니다.") + Boolean imagesRequired ) { } diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java index 0c704e81..e30c465d 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java @@ -1,38 +1,35 @@ package com.opus.opus.modules.contest.application.dto.response; import com.opus.opus.modules.contest.domain.ContestTeamTemplate; -import com.opus.opus.modules.contest.domain.ContestTeamTemplateFieldType; public record TeamTemplateResponse( - Long contestId, - ContestTeamTemplateFieldType division, - ContestTeamTemplateFieldType projectName, - ContestTeamTemplateFieldType teamName, - ContestTeamTemplateFieldType leader, - ContestTeamTemplateFieldType teamMembers, - ContestTeamTemplateFieldType professor, - ContestTeamTemplateFieldType githubPath, - ContestTeamTemplateFieldType youtubePath, - ContestTeamTemplateFieldType productionPath, - ContestTeamTemplateFieldType overview, - ContestTeamTemplateFieldType poster, - ContestTeamTemplateFieldType images + Boolean divisionRequired, + Boolean projectNameRequired, + Boolean teamNameRequired, + Boolean leaderRequired, + Boolean teamMembersRequired, + Boolean professorRequired, + Boolean githubPathRequired, + Boolean youtubePathRequired, + Boolean productionPathRequired, + Boolean overviewRequired, + Boolean posterRequired, + Boolean imagesRequired ) { public static TeamTemplateResponse from(final ContestTeamTemplate template) { return new TeamTemplateResponse( - template.getContest().getId(), - template.getDivision(), - template.getProjectName(), - template.getTeamName(), - template.getLeader(), - template.getTeamMembers(), - template.getProfessor(), - template.getGithubPath(), - template.getYoutubePath(), - template.getProductionPath(), - template.getOverview(), - template.getPoster(), - template.getImages() + template.getDivisionRequired(), + template.getProjectNameRequired(), + template.getTeamNameRequired(), + template.getLeaderRequired(), + template.getTeamMembersRequired(), + template.getProfessorRequired(), + template.getGithubPathRequired(), + template.getYoutubePathRequired(), + template.getProductionPathRequired(), + template.getOverviewRequired(), + template.getPosterRequired(), + template.getImagesRequired() ); } } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java index 3b7ae5cb..916ad783 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java @@ -5,8 +5,6 @@ import com.opus.opus.global.base.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -34,53 +32,41 @@ public class ContestTeamTemplate extends BaseEntity { @JoinColumn(name = "contest_id", nullable = false, unique = true) private Contest contest; - @Column(name = "division", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType division; + @Column(name = "division_required", nullable = false) + private Boolean divisionRequired; - @Column(name = "project_name", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType projectName; + @Column(name = "project_name_required", nullable = false) + private Boolean projectNameRequired; - @Column(name = "team_name", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType teamName; + @Column(name = "team_name_required", nullable = false) + private Boolean teamNameRequired; - @Column(name = "leader", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType leader; + @Column(name = "leader_required", nullable = false) + private Boolean leaderRequired; - @Column(name = "team_members", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType teamMembers; + @Column(name = "team_members_required", nullable = false) + private Boolean teamMembersRequired; - @Column(name = "professor", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType professor; + @Column(name = "professor_required", nullable = false) + private Boolean professorRequired; - @Column(name = "github_path", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType githubPath; + @Column(name = "github_path_required", nullable = false) + private Boolean githubPathRequired; - @Column(name = "youtube_path", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType youtubePath; + @Column(name = "youtube_path_required", nullable = false) + private Boolean youtubePathRequired; - @Column(name = "production_path", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType productionPath; + @Column(name = "production_path_required", nullable = false) + private Boolean productionPathRequired; - @Column(name = "overview", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType overview; + @Column(name = "overview_required", nullable = false) + private Boolean overviewRequired; - @Column(name = "poster", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType poster; + @Column(name = "poster_required", nullable = false) + private Boolean posterRequired; - @Column(name = "images", nullable = false) - @Enumerated(EnumType.STRING) - private ContestTeamTemplateFieldType images; + @Column(name = "images_required", nullable = false) + private Boolean imagesRequired; @Column(nullable = false) private Boolean isDeleted; @@ -88,58 +74,58 @@ public class ContestTeamTemplate extends BaseEntity { @Builder private ContestTeamTemplate( final Contest contest, - final ContestTeamTemplateFieldType division, - final ContestTeamTemplateFieldType projectName, - final ContestTeamTemplateFieldType teamName, - final ContestTeamTemplateFieldType leader, - final ContestTeamTemplateFieldType teamMembers, - final ContestTeamTemplateFieldType professor, - final ContestTeamTemplateFieldType githubPath, - final ContestTeamTemplateFieldType youtubePath, - final ContestTeamTemplateFieldType productionPath, - final ContestTeamTemplateFieldType overview, - final ContestTeamTemplateFieldType poster, - final ContestTeamTemplateFieldType images) { + final Boolean divisionRequired, + final Boolean projectNameRequired, + final Boolean teamNameRequired, + final Boolean leaderRequired, + final Boolean teamMembersRequired, + final Boolean professorRequired, + final Boolean githubPathRequired, + final Boolean youtubePathRequired, + final Boolean productionPathRequired, + final Boolean overviewRequired, + final Boolean posterRequired, + final Boolean imagesRequired) { this.contest = contest; - this.division = division; - this.projectName = projectName; - this.teamName = teamName; - this.leader = leader; - this.teamMembers = teamMembers; - this.professor = professor; - this.githubPath = githubPath; - this.youtubePath = youtubePath; - this.productionPath = productionPath; - this.overview = overview; - this.poster = poster; - this.images = images; + this.divisionRequired = divisionRequired; + this.projectNameRequired = projectNameRequired; + this.teamNameRequired = teamNameRequired; + this.leaderRequired = leaderRequired; + this.teamMembersRequired = teamMembersRequired; + this.professorRequired = professorRequired; + this.githubPathRequired = githubPathRequired; + this.youtubePathRequired = youtubePathRequired; + this.productionPathRequired = productionPathRequired; + this.overviewRequired = overviewRequired; + this.posterRequired = posterRequired; + this.imagesRequired = imagesRequired; this.isDeleted = false; } public void updateTemplate( - final ContestTeamTemplateFieldType division, - final ContestTeamTemplateFieldType projectName, - final ContestTeamTemplateFieldType teamName, - final ContestTeamTemplateFieldType leader, - final ContestTeamTemplateFieldType teamMembers, - final ContestTeamTemplateFieldType professor, - final ContestTeamTemplateFieldType githubPath, - final ContestTeamTemplateFieldType youtubePath, - final ContestTeamTemplateFieldType productionPath, - final ContestTeamTemplateFieldType overview, - final ContestTeamTemplateFieldType poster, - final ContestTeamTemplateFieldType images) { - this.division = division; - this.projectName = projectName; - this.teamName = teamName; - this.leader = leader; - this.teamMembers = teamMembers; - this.professor = professor; - this.githubPath = githubPath; - this.youtubePath = youtubePath; - this.productionPath = productionPath; - this.overview = overview; - this.poster = poster; - this.images = images; + final Boolean divisionRequired, + final Boolean projectNameRequired, + final Boolean teamNameRequired, + final Boolean leaderRequired, + final Boolean teamMembersRequired, + final Boolean professorRequired, + final Boolean githubPathRequired, + final Boolean youtubePathRequired, + final Boolean productionPathRequired, + final Boolean overviewRequired, + final Boolean posterRequired, + final Boolean imagesRequired) { + this.divisionRequired = divisionRequired; + this.projectNameRequired = projectNameRequired; + this.teamNameRequired = teamNameRequired; + this.leaderRequired = leaderRequired; + this.teamMembersRequired = teamMembersRequired; + this.professorRequired = professorRequired; + this.githubPathRequired = githubPathRequired; + this.youtubePathRequired = youtubePathRequired; + this.productionPathRequired = productionPathRequired; + this.overviewRequired = overviewRequired; + this.posterRequired = posterRequired; + this.imagesRequired = imagesRequired; } } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java deleted file mode 100644 index 661ac56d..00000000 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplateFieldType.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.opus.opus.modules.contest.domain; - -import lombok.Getter; - -@Getter -public enum ContestTeamTemplateFieldType { - REQUIRED(1), - OPTIONAL(2), - HIDDEN(3), - ; - - private final long id; - - ContestTeamTemplateFieldType(final long id) { - this.id = id; - } -} - From 0d14298d57f7f30f9be25da3acd5feafb061892e Mon Sep 17 00:00:00 2001 From: myeowon Date: Sun, 22 Feb 2026 16:42:07 +0900 Subject: [PATCH 09/24] =?UTF-8?q?feat=20:=20ContestTemplate=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/contest/api/ContestController.java | 18 +++++++++--------- .../ContestTeamTemplateCommandService.java | 8 ++++---- .../ContestTeamTemplateQueryService.java | 4 ++-- .../ContestTeamTemplateConvenience.java | 14 +++++++------- ...equest.java => ContestTemplateRequest.java} | 7 +------ ...ponse.java => ContestTemplateResponse.java} | 8 ++++---- ...tTeamTemplate.java => ContestTemplate.java} | 6 +++--- .../dao/ContestTeamTemplateRepository.java | 11 ----------- .../domain/dao/ContestTemplateRepository.java | 11 +++++++++++ src/main/resources/schema.sql | 2 +- 10 files changed, 42 insertions(+), 47 deletions(-) rename src/main/java/com/opus/opus/modules/contest/application/dto/request/{TeamTemplateRequest.java => ContestTemplateRequest.java} (89%) rename src/main/java/com/opus/opus/modules/contest/application/dto/response/{TeamTemplateResponse.java => ContestTemplateResponse.java} (82%) rename src/main/java/com/opus/opus/modules/contest/domain/{ContestTeamTemplate.java => ContestTemplate.java} (96%) delete mode 100644 src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java create mode 100644 src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTemplateRepository.java 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 c96f460e..01db2185 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 @@ -6,14 +6,14 @@ import com.opus.opus.modules.contest.application.ContestTeamTemplateQueryService; 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.TeamTemplateRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; +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.TeamTemplateResponse; -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.ContestVotesLimitResponse; +import com.opus.opus.modules.contest.application.dto.response.TeamTemplateResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.team.application.dto.ImageResponse; import jakarta.validation.Valid; @@ -113,16 +113,16 @@ public ResponseEntity> getCurrentContests() { return ResponseEntity.ok(responses); } - @GetMapping("/{contestId}/team-detail-template") - public ResponseEntity getTeamDetailTemplate(@PathVariable final Long contestId) { + @GetMapping("/{contestId}/template") + public ResponseEntity getContestTemplate(@PathVariable final Long contestId) { TeamTemplateResponse response = contestTeamTemplateQueryService.getTeamTemplate(contestId); return ResponseEntity.ok(response); } - @PutMapping("/{contestId}/team-detail-template") + @PutMapping("/{contestId}/template") @Secured("ROLE_관리자") - public ResponseEntity updateTeamDetailTemplate(@PathVariable final Long contestId, - @Valid @RequestBody final TeamTemplateRequest request) { + public ResponseEntity updateContestTemplate(@PathVariable final Long contestId, + @Valid @RequestBody final ContestTemplateRequest request) { contestTeamTemplateCommandService.updateTeamTemplate(contestId, request); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java index 27246ebe..adeb73ed 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java @@ -2,8 +2,8 @@ import com.opus.opus.modules.contest.application.convenience.ContestConvenience; import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; -import com.opus.opus.modules.contest.application.dto.request.TeamTemplateRequest; -import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; +import com.opus.opus.modules.contest.domain.ContestTemplate; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,10 +16,10 @@ public class ContestTeamTemplateCommandService { private final ContestConvenience contestConvenience; private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; - public void updateTeamTemplate(final Long contestId, final TeamTemplateRequest request) { + public void updateTeamTemplate(final Long contestId, final ContestTemplateRequest request) { contestConvenience.getValidateExistContest(contestId); - final ContestTeamTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate( + final ContestTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate( contestId); template.updateTemplate( diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java index 5a4cd6f8..0039870e 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java +++ b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java @@ -3,7 +3,7 @@ import com.opus.opus.modules.contest.application.convenience.ContestConvenience; import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; import com.opus.opus.modules.contest.application.dto.response.TeamTemplateResponse; -import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import com.opus.opus.modules.contest.domain.ContestTemplate; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,7 +18,7 @@ public class ContestTeamTemplateQueryService { public TeamTemplateResponse getTeamTemplate(final Long contestId) { contestConvenience.getValidateExistContest(contestId); - final ContestTeamTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate(contestId); + final ContestTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate(contestId); return TeamTemplateResponse.from(template); } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java index b8073c8a..e74fb35b 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java @@ -3,8 +3,8 @@ import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; import com.opus.opus.modules.contest.domain.Contest; -import com.opus.opus.modules.contest.domain.ContestTeamTemplate; -import com.opus.opus.modules.contest.domain.dao.ContestTeamTemplateRepository; +import com.opus.opus.modules.contest.domain.ContestTemplate; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; import java.util.HashMap; import java.util.Map; @@ -17,17 +17,17 @@ @Transactional(readOnly = true) public class ContestTeamTemplateConvenience { - private final ContestTeamTemplateRepository contestTeamTemplateRepository; + private final ContestTemplateRepository contestTemplateRepository; - public ContestTeamTemplate getValidateExistTemplate(final Long contestId) { - return contestTeamTemplateRepository.findByContestId(contestId) + public ContestTemplate getValidateExistTemplate(final Long contestId) { + return contestTemplateRepository.findByContestId(contestId) .orElseThrow(() -> new ContestTeamTemplateException(NOT_FOUND_TEMPLATE)); } public void createTemplate(final Contest contest, final String categoryName) { final Map settings = getTemplateDefaultSettings(categoryName); - ContestTeamTemplate template = ContestTeamTemplate.builder() + ContestTemplate template = ContestTemplate.builder() .contest(contest) .divisionRequired(settings.get("division")) .projectNameRequired(settings.get("projectName")) @@ -43,7 +43,7 @@ public void createTemplate(final Contest contest, final String categoryName) { .imagesRequired(settings.get("images")) .build(); - contestTeamTemplateRepository.save(template); + contestTemplateRepository.save(template); } private Map getTemplateDefaultSettings(final String categoryName) { diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java similarity index 89% rename from src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java rename to src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java index 17153b71..26dd5c91 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/request/TeamTemplateRequest.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java @@ -2,11 +2,7 @@ import jakarta.validation.constraints.NotNull; -/** - * 필수 항목 설정 요청 - * 각 항목은 필수 여부만 설정 가능합니다. (true: 필수 입력, false: 선택 입력) - */ -public record TeamTemplateRequest( +public record ContestTemplateRequest( @NotNull(message = "분과 필수 여부는 필수입니다.") Boolean divisionRequired, @NotNull(message = "프로젝트명 필수 여부는 필수입니다.") @@ -33,4 +29,3 @@ public record TeamTemplateRequest( Boolean imagesRequired ) { } - diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java similarity index 82% rename from src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java rename to src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java index e30c465d..9ae67256 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/response/TeamTemplateResponse.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java @@ -1,8 +1,8 @@ package com.opus.opus.modules.contest.application.dto.response; -import com.opus.opus.modules.contest.domain.ContestTeamTemplate; +import com.opus.opus.modules.contest.domain.ContestTemplate; -public record TeamTemplateResponse( +public record ContestTemplateResponse( Boolean divisionRequired, Boolean projectNameRequired, Boolean teamNameRequired, @@ -16,8 +16,8 @@ public record TeamTemplateResponse( Boolean posterRequired, Boolean imagesRequired ) { - public static TeamTemplateResponse from(final ContestTeamTemplate template) { - return new TeamTemplateResponse( + public static ContestTemplateResponse from(final ContestTemplate template) { + return new ContestTemplateResponse( template.getDivisionRequired(), template.getProjectNameRequired(), template.getTeamNameRequired(), diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java similarity index 96% rename from src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java rename to src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java index 916ad783..cb85a4c3 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTeamTemplate.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java @@ -21,8 +21,8 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("is_deleted = false") -@SQLDelete(sql = "UPDATE contest_team_template SET is_deleted = true WHERE id = ?") -public class ContestTeamTemplate extends BaseEntity { +@SQLDelete(sql = "UPDATE contest_template SET is_deleted = true WHERE id = ?") +public class ContestTemplate extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -72,7 +72,7 @@ public class ContestTeamTemplate extends BaseEntity { private Boolean isDeleted; @Builder - private ContestTeamTemplate( + private ContestTemplate( final Contest contest, final Boolean divisionRequired, final Boolean projectNameRequired, diff --git a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java deleted file mode 100644 index db4d3042..00000000 --- a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTeamTemplateRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.opus.opus.modules.contest.domain.dao; - -import com.opus.opus.modules.contest.domain.ContestTeamTemplate; -import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ContestTeamTemplateRepository extends JpaRepository { - - Optional findByContestId(final Long contestId); - -} diff --git a/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTemplateRepository.java b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTemplateRepository.java new file mode 100644 index 00000000..4dff8131 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/domain/dao/ContestTemplateRepository.java @@ -0,0 +1,11 @@ +package com.opus.opus.modules.contest.domain.dao; + +import com.opus.opus.modules.contest.domain.ContestTemplate; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContestTemplateRepository extends JpaRepository { + + Optional findByContestId(final Long contestId); + +} diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 7b5b613d..fa80e809 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -51,7 +51,7 @@ CREATE TABLE `contest_category` ( PRIMARY KEY (`id`) ); -CREATE TABLE `contest_team_template` ( +CREATE TABLE `contest_template` ( `id` bigint NOT NULL AUTO_INCREMENT, `created_at` datetime(6) DEFAULT NULL, `updated_at` datetime(6) DEFAULT NULL, From 7f2f0a2c9bf73f34c0fd1303a866794f637f344a Mon Sep 17 00:00:00 2001 From: myeowon Date: Sun, 22 Feb 2026 17:10:00 +0900 Subject: [PATCH 10/24] =?UTF-8?q?refactor=20:=20Controller/Service=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=ED=86=B5=EC=9D=BC=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20ContestTemplate=EC=9D=84=20Contest=EC=97=90=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest/api/ContestController.java | 12 +-- .../application/ContestCommandService.java | 97 +++++++++++++++++-- .../application/ContestQueryService.java | 12 ++- .../ContestTeamTemplateCommandService.java | 40 -------- .../ContestTeamTemplateQueryService.java | 24 ----- .../convenience/ContestConvenience.java | 10 ++ .../ContestTeamTemplateConvenience.java | 97 ------------------- .../com/opus/opus/restdocs/RestDocsTest.java | 18 +--- 8 files changed, 120 insertions(+), 190 deletions(-) delete mode 100644 src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java delete mode 100644 src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java delete mode 100644 src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java 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 01db2185..7de7a49d 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 @@ -2,8 +2,6 @@ import com.opus.opus.modules.contest.application.ContestCommandService; import com.opus.opus.modules.contest.application.ContestQueryService; -import com.opus.opus.modules.contest.application.ContestTeamTemplateCommandService; -import com.opus.opus.modules.contest.application.ContestTeamTemplateQueryService; 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.ContestTemplateRequest; @@ -12,8 +10,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.ContestTemplateResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; -import com.opus.opus.modules.contest.application.dto.response.TeamTemplateResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.team.application.dto.ImageResponse; import jakarta.validation.Valid; @@ -44,8 +42,6 @@ public class ContestController { private final ContestCommandService contestCommandService; private final ContestQueryService contestQueryService; - private final ContestTeamTemplateCommandService contestTeamTemplateCommandService; - private final ContestTeamTemplateQueryService contestTeamTemplateQueryService; @GetMapping("/{contestId}/image/banner") public ResponseEntity getContestBanner(@PathVariable final Long contestId) { @@ -114,8 +110,8 @@ public ResponseEntity> getCurrentContests() { } @GetMapping("/{contestId}/template") - public ResponseEntity getContestTemplate(@PathVariable final Long contestId) { - TeamTemplateResponse response = contestTeamTemplateQueryService.getTeamTemplate(contestId); + public ResponseEntity getContestTemplate(@PathVariable final Long contestId) { + ContestTemplateResponse response = contestQueryService.getContestTemplate(contestId); return ResponseEntity.ok(response); } @@ -123,7 +119,7 @@ public ResponseEntity getContestTemplate(@PathVariable fin @Secured("ROLE_관리자") public ResponseEntity updateContestTemplate(@PathVariable final Long contestId, @Valid @RequestBody final ContestTemplateRequest request) { - contestTeamTemplateCommandService.updateTeamTemplate(contestId, request); + contestCommandService.updateContestTemplate(contestId, request); 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 ce6ca42b..15e1ed26 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,11 +1,11 @@ package com.opus.opus.modules.contest.application; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.*; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_CURRENT_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.ALREADY_NOT_CURRENT_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CANNOT_CHANGE_VOTES_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CURRENT_CONTEST_LIMIT_EXCEEDED; +import static com.opus.opus.modules.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; @@ -13,21 +13,23 @@ 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.ContestTeamTemplateConvenience; import com.opus.opus.modules.contest.application.dto.request.ContestRequest; +import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; 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.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; 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 java.util.HashMap; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,15 +44,14 @@ public class ContestCommandService { private final ContestRepository contestRepository; private final FileRepository fileRepository; + private final ContestTemplateRepository contestTemplateRepository; private final ContestConvenience contestConvenience; private final ContestCategoryConvenience contestCategoryConvenience; private final TeamConvenience teamConvenience; - private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; private final FileStorageUtil fileStorageUtil; - public void saveBannerImage(final Long contestId, final MultipartFile image) { contestConvenience.getValidateExistContest(contestId); @@ -79,7 +80,7 @@ public ContestResponse createContest(final ContestRequest request) { contestRepository.save(contest); // 템플릿 자동 생성 - contestTeamTemplateConvenience.createTemplate(contest, contestCategory.getCategoryName()); + createTemplate(contest, contestCategory.getCategoryName()); return ContestResponse.from(contest, contestCategory.getCategoryName()); } @@ -155,4 +156,86 @@ private void validateCurrentContestLimit(final long currentCount) { throw new ContestException(CURRENT_CONTEST_LIMIT_EXCEEDED); } } + + public void createTemplate(final Contest contest, final String categoryName) { + final Map settings = getDefaultTemplate(categoryName); + + ContestTemplate template = ContestTemplate.builder() + .contest(contest) + .divisionRequired(settings.get("division")) + .projectNameRequired(settings.get("projectName")) + .teamNameRequired(settings.get("teamName")) + .leaderRequired(settings.get("leader")) + .teamMembersRequired(settings.get("teamMembers")) + .professorRequired(settings.get("professor")) + .githubPathRequired(settings.get("githubPath")) + .youtubePathRequired(settings.get("youtubePath")) + .productionPathRequired(settings.get("productionPath")) + .overviewRequired(settings.get("overview")) + .posterRequired(settings.get("poster")) + .imagesRequired(settings.get("images")) + .build(); + + contestTemplateRepository.save(template); + } + + public void updateContestTemplate(final Long contestId, final ContestTemplateRequest request) { + contestConvenience.getValidateExistContest(contestId); + final ContestTemplate template = contestConvenience.getValidateExistTemplate(contestId); + + template.updateTemplate( + request.divisionRequired(), request.projectNameRequired(), request.teamNameRequired(), + request.leaderRequired(), request.teamMembersRequired(), request.professorRequired(), + request.githubPathRequired(), request.youtubePathRequired(), request.productionPathRequired(), + request.overviewRequired(), request.posterRequired(), request.imagesRequired() + ); + } + + private Map getDefaultTemplate(final String categoryName) { + Map map = new HashMap<>(); + + if (categoryName.contains("창의융합")) { + map.put("division", true); + map.put("projectName", true); + map.put("teamName", true); + map.put("leader", true); + map.put("teamMembers", true); + map.put("professor", false); + map.put("githubPath", true); + map.put("youtubePath", false); + map.put("productionPath", false); + map.put("overview", true); + map.put("poster", true); + map.put("images", true); + + } else if (categoryName.contains("캡스톤")) { + map.put("division", true); + map.put("projectName", true); + map.put("teamName", true); + map.put("leader", true); + map.put("teamMembers", true); + map.put("professor", true); + map.put("githubPath", true); + map.put("youtubePath", true); + map.put("productionPath", false); + map.put("overview", true); + map.put("poster", false); + map.put("images", true); + + } else { + map.put("division", false); + map.put("projectName", false); + map.put("teamName", false); + map.put("leader", false); + map.put("teamMembers", false); + map.put("professor", false); + map.put("githubPath", false); + map.put("youtubePath", false); + map.put("productionPath", false); + map.put("overview", false); + map.put("poster", false); + map.put("images", false); + } + return map; + } } 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..56d006aa 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 @@ -10,11 +10,14 @@ 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.VotePeriodResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse; 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.domain.Contest; import com.opus.opus.modules.contest.domain.ContestCategory; +import com.opus.opus.modules.contest.domain.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; 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; @@ -34,6 +37,7 @@ public class ContestQueryService { private final FileStorageUtil fileStorageUtil; private final ContestRepository contestRepository; + private final ContestTemplateRepository contestTemplateRepository; private final ContestCategoryConvenience contestCategoryConvenience; private final ContestConvenience contestConvenience; @@ -89,4 +93,10 @@ private void checkImageConverted(final File findFile) { throw new FileException(NOT_WEBP_CONVERTED); } } + + public ContestTemplateResponse getContestTemplate(final Long contestId) { + contestConvenience.getValidateExistContest(contestId); + final ContestTemplate template = contestConvenience.getValidateExistTemplate(contestId); + return ContestTemplateResponse.from(template); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java deleted file mode 100644 index adeb73ed..00000000 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateCommandService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.opus.opus.modules.contest.application; - -import com.opus.opus.modules.contest.application.convenience.ContestConvenience; -import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; -import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; -import com.opus.opus.modules.contest.domain.ContestTemplate; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@Transactional -@RequiredArgsConstructor -public class ContestTeamTemplateCommandService { - - private final ContestConvenience contestConvenience; - private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; - - public void updateTeamTemplate(final Long contestId, final ContestTemplateRequest request) { - - contestConvenience.getValidateExistContest(contestId); - final ContestTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate( - contestId); - - template.updateTemplate( - request.divisionRequired(), - request.projectNameRequired(), - request.teamNameRequired(), - request.leaderRequired(), - request.teamMembersRequired(), - request.professorRequired(), - request.githubPathRequired(), - request.youtubePathRequired(), - request.productionPathRequired(), - request.overviewRequired(), - request.posterRequired(), - request.imagesRequired() - ); - } -} diff --git a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java b/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java deleted file mode 100644 index 0039870e..00000000 --- a/src/main/java/com/opus/opus/modules/contest/application/ContestTeamTemplateQueryService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.opus.opus.modules.contest.application; - -import com.opus.opus.modules.contest.application.convenience.ContestConvenience; -import com.opus.opus.modules.contest.application.convenience.ContestTeamTemplateConvenience; -import com.opus.opus.modules.contest.application.dto.response.TeamTemplateResponse; -import com.opus.opus.modules.contest.domain.ContestTemplate; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class ContestTeamTemplateQueryService { - - private final ContestConvenience contestConvenience; - private final ContestTeamTemplateConvenience contestTeamTemplateConvenience; - - public TeamTemplateResponse getTeamTemplate(final Long contestId) { - contestConvenience.getValidateExistContest(contestId); - final ContestTemplate template = contestTeamTemplateConvenience.getValidateExistTemplate(contestId); - return TeamTemplateResponse.from(template); - } -} 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..d49a5769 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,10 +4,14 @@ 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 com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -19,6 +23,7 @@ public class ContestConvenience { private final ContestRepository contestRepository; + private final ContestTemplateRepository contestTemplateRepository; public Contest getValidateExistContest(final Long contestId) { return contestRepository.findById(contestId).orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST)); @@ -47,4 +52,9 @@ public long countCurrentContests() { public List getCurrentContests() { return contestRepository.findAllByIsCurrentTrue(); } + + public ContestTemplate getValidateExistTemplate(final Long contestId) { + return contestTemplateRepository.findByContestId(contestId) + .orElseThrow(() -> new ContestTeamTemplateException(NOT_FOUND_TEMPLATE)); + } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java deleted file mode 100644 index e74fb35b..00000000 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTeamTemplateConvenience.java +++ /dev/null @@ -1,97 +0,0 @@ -package com.opus.opus.modules.contest.application.convenience; - -import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; - -import com.opus.opus.modules.contest.domain.Contest; -import com.opus.opus.modules.contest.domain.ContestTemplate; -import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; -import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; -import java.util.HashMap; -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 ContestTeamTemplateConvenience { - - private final ContestTemplateRepository contestTemplateRepository; - - public ContestTemplate getValidateExistTemplate(final Long contestId) { - return contestTemplateRepository.findByContestId(contestId) - .orElseThrow(() -> new ContestTeamTemplateException(NOT_FOUND_TEMPLATE)); - } - - public void createTemplate(final Contest contest, final String categoryName) { - final Map settings = getTemplateDefaultSettings(categoryName); - - ContestTemplate template = ContestTemplate.builder() - .contest(contest) - .divisionRequired(settings.get("division")) - .projectNameRequired(settings.get("projectName")) - .teamNameRequired(settings.get("teamName")) - .leaderRequired(settings.get("leader")) - .teamMembersRequired(settings.get("teamMembers")) - .professorRequired(settings.get("professor")) - .githubPathRequired(settings.get("githubPath")) - .youtubePathRequired(settings.get("youtubePath")) - .productionPathRequired(settings.get("productionPath")) - .overviewRequired(settings.get("overview")) - .posterRequired(settings.get("poster")) - .imagesRequired(settings.get("images")) - .build(); - - contestTemplateRepository.save(template); - } - - private Map getTemplateDefaultSettings(final String categoryName) { - Map map = new HashMap<>(); - - if (categoryName.contains("창의융합")) { - map.put("division", true); - map.put("projectName", true); - map.put("teamName", true); - map.put("leader", true); - map.put("teamMembers", true); - map.put("professor", false); - map.put("githubPath", true); - map.put("youtubePath", false); - map.put("productionPath", false); - map.put("overview", true); - map.put("poster", true); - map.put("images", true); - - } else if (categoryName.contains("캡스톤")) { - map.put("division", true); - map.put("projectName", true); - map.put("teamName", true); - map.put("leader", true); - map.put("teamMembers", true); - map.put("professor", true); - map.put("githubPath", true); - map.put("youtubePath", true); - map.put("productionPath", false); - map.put("overview", true); - map.put("poster", false); - map.put("images", true); - - } else { - map.put("division", false); - map.put("projectName", false); - map.put("teamName", false); - map.put("leader", false); - map.put("teamMembers", false); - map.put("professor", false); - map.put("githubPath", false); - map.put("youtubePath", false); - map.put("productionPath", false); - map.put("overview", false); - map.put("poster", false); - map.put("images", false); - } - return map; - } - -} diff --git a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java index 9ff94f02..d5a38cbc 100644 --- a/src/test/java/com/opus/opus/restdocs/RestDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/RestDocsTest.java @@ -10,23 +10,21 @@ 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.contest.application.ContestTeamTemplateCommandService; -import com.opus.opus.modules.contest.application.ContestTeamTemplateQueryService; import com.opus.opus.modules.member.api.MemberController; import com.opus.opus.modules.member.application.MemberCommandService; import com.opus.opus.modules.member.application.MemberQueryService; import com.opus.opus.modules.member.domain.dao.MemberRepository; -import com.opus.opus.modules.team.api.TeamCommentController; -import com.opus.opus.modules.team.application.TeamCommentCommandService; -import com.opus.opus.modules.team.application.TeamCommentQueryService; import com.opus.opus.modules.notice.api.NoticeController; import com.opus.opus.modules.notice.application.NoticeCommandService; import com.opus.opus.modules.notice.application.NoticeQueryService; +import com.opus.opus.modules.team.api.TeamCommentController; import com.opus.opus.modules.team.api.TeamController; -import com.opus.opus.modules.team.application.TeamCommandService; -import com.opus.opus.modules.team.application.TeamQueryService; import com.opus.opus.modules.team.api.TeamMemberController; +import com.opus.opus.modules.team.application.TeamCommandService; +import com.opus.opus.modules.team.application.TeamCommentCommandService; +import com.opus.opus.modules.team.application.TeamCommentQueryService; import com.opus.opus.modules.team.application.TeamMemberCommandService; +import com.opus.opus.modules.team.application.TeamQueryService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -87,12 +85,6 @@ public abstract class RestDocsTest extends ApiTestHelper { @MockitoBean protected ContestQueryService contestQueryService; - @MockitoBean - protected ContestTeamTemplateCommandService contestTeamTemplateCommandService; - - @MockitoBean - protected ContestTeamTemplateQueryService contestTeamTemplateQueryService; - // Setting @Autowired protected WebApplicationContext context; From 88381b71250fe3a6f32dad60eb9ac5aaffb78a6f Mon Sep 17 00:00:00 2001 From: myeowon Date: Sun, 22 Feb 2026 19:32:23 +0900 Subject: [PATCH 11/24] =?UTF-8?q?chore=20:=20import=EB=AC=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../opus/modules/contest/api/ContestController.java | 9 ++------- .../contest/application/ContestCommandService.java | 10 ++++------ .../contest/application/ContestQueryService.java | 2 -- .../com/opus/opus/modules/member/domain/Member.java | 4 ++-- 4 files changed, 8 insertions(+), 17 deletions(-) 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 22072ab8..ad12f0e1 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; @@ -9,11 +10,9 @@ import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; 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.request.ContestTemplateRequest; -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.ContestRankingResponse; 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.ContestTemplateResponse; @@ -22,12 +21,8 @@ import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.team.application.TeamQueryService; -import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse; -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; import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; -import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; 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 6986f0e7..1ba74273 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 @@ -13,7 +13,6 @@ 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.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; @@ -27,7 +26,6 @@ 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.request.ContestTemplateRequest; import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentToggleResponse; @@ -44,12 +42,11 @@ 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 java.util.Optional; import com.opus.opus.modules.team.domain.Team; -import java.util.List; -import java.util.Map; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -77,7 +74,8 @@ public class ContestCommandService { public void saveBannerImage(final Long contestId, final MultipartFile image) { contestConvenience.getValidateExistContest(contestId); - final Optional existingFile = fileRepository.findByReferenceIdAndReferenceTypeAndImageType(contestId, CONTEST, BANNER); + final Optional existingFile = fileRepository.findByReferenceIdAndReferenceTypeAndImageType(contestId, + CONTEST, BANNER); existingFile.ifPresent(this::checkWebpConverted); fileStorageUtil.storeFile(image, contestId, CONTEST, BANNER); 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 3a9250fe..478c8f52 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 @@ -12,8 +12,6 @@ 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.application.dto.response.ContestTemplateResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; diff --git a/src/main/java/com/opus/opus/modules/member/domain/Member.java b/src/main/java/com/opus/opus/modules/member/domain/Member.java index f060d2ea..274d5ca5 100644 --- a/src/main/java/com/opus/opus/modules/member/domain/Member.java +++ b/src/main/java/com/opus/opus/modules/member/domain/Member.java @@ -1,7 +1,7 @@ package com.opus.opus.modules.member.domain; import static jakarta.persistence.FetchType.EAGER; -import static jakarta.persistence.FetchType.LAZY; + import com.opus.opus.global.base.BaseEntity; import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; @@ -58,7 +58,7 @@ public class Member extends BaseEntity { @Builder private Member(final String name, final String email, final String password, final String studentId, - final Set roles) { + final Set roles) { this.name = name; this.email = email; this.password = password; From 6e62609c653623f61afe2863099def36363a5851 Mon Sep 17 00:00:00 2001 From: myeowon Date: Sun, 22 Feb 2026 19:56:35 +0900 Subject: [PATCH 12/24] =?UTF-8?q?chore=20:=20=EC=B6=A9=EB=8F=8C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team/application/TeamQueryService.java | 45 ++++++++++--------- .../domain/dao/TeamCommentRepository.java | 2 - src/main/resources/schema.sql | 2 +- .../restdocs/docs/ContestApiDocsTest.java | 6 ++- .../opus/restdocs/docs/TeamApiDocsTest.java | 24 ++++++---- 5 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java b/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java index 671c4401..61287306 100644 --- a/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java +++ b/src/main/java/com/opus/opus/modules/team/application/TeamQueryService.java @@ -1,8 +1,6 @@ package com.opus.opus.modules.team.application; import static com.opus.opus.modules.file.domain.FileImageType.POSTER; -import static com.opus.opus.modules.file.domain.FileImageType.POSTER; -import static com.opus.opus.modules.file.domain.FileImageType.PREVIEW; import static com.opus.opus.modules.file.domain.FileImageType.THUMBNAIL; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TEAM; import static com.opus.opus.modules.file.domain.ReferenceDomainType.TRACK; @@ -11,6 +9,7 @@ import com.opus.opus.global.util.FileStorageUtil; import com.opus.opus.modules.contest.application.convenience.ContestConvenience; +import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.file.application.convenience.FileConvenience; @@ -20,16 +19,15 @@ import com.opus.opus.modules.file.exception.FileException; import com.opus.opus.modules.team.application.convenience.TeamConvenience; import com.opus.opus.modules.team.application.dto.ImageResponse; -import com.opus.opus.modules.team.domain.Team; -import java.util.Optional; import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; -import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; +import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.dao.TeamRankingResult; import com.opus.opus.modules.team.domain.dao.TeamRepository; import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; import com.opus.opus.modules.team.domain.dao.VoteStatisticsResult; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.antlr.v4.runtime.misc.Pair; import org.springframework.core.io.Resource; @@ -51,6 +49,25 @@ public class TeamQueryService { private final FileStorageUtil fileStorageUtil; + private static List applyDenseRanking(List votesPerTeam) { + List responseList = new ArrayList<>(); + int curRank = 0; // 현재 순위 + long prevCount = -1; // 이전 팀 투표 수 + for (TeamRankingResult result : votesPerTeam) { + // 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지 + if (prevCount != result.voteCount()) { + curRank++; + } + prevCount = result.voteCount(); + + responseList.add( + new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(), + result.trackName(), result.voteCount())); + } + + return responseList; + } + public ImageResponse getPreviewImage(final Long teamId, final Long imageId) { teamConvenience.validateExistTeam(teamId); final File findFile = fileRepository.findById(imageId).orElseThrow(() -> new FileException(NOT_EXISTS_PREVIEW)); @@ -115,7 +132,8 @@ public ContestVoteStatisticsResponse getVoteStatistics(Long contestId) { private ImageResponse getImage(final Long teamId, final FileImageType fileImageType) { teamConvenience.validateExistTeam(teamId); - final File findFile = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, fileImageType); + final File findFile = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(teamId, TEAM, + fileImageType); return getImageResponse(findFile); } @@ -130,19 +148,4 @@ private void checkImageConverted(final File findFile) { throw new FileException(NOT_WEBP_CONVERTED); } } - - private static List applyDenseRanking(List votesPerTeam) { - List responseList = new ArrayList<>(); - int curRank = 0; // 현재 순위 - long prevCount = -1; // 이전 팀 투표 수 - for (TeamRankingResult result : votesPerTeam) { - // 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지 - if (prevCount != result.voteCount()) curRank++; - prevCount = result.voteCount(); - - responseList.add(new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(), result.trackName(), result.voteCount())); - } - - return responseList; - } } diff --git a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java index d501f110..1cb6b77f 100644 --- a/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java +++ b/src/main/java/com/opus/opus/modules/team/domain/dao/TeamCommentRepository.java @@ -6,6 +6,4 @@ public interface TeamCommentRepository extends JpaRepository { List findAllByTeamIdOrderByIdDesc(Long id); - - void deleteAllByTeamId(Long teamId); } diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 99ebf3a2..ae45aee1 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -3,7 +3,7 @@ USE opus; DROP TABLE IF EXISTS `contest`; DROP TABLE IF EXISTS `contest_award`; DROP TABLE IF EXISTS `contest_category`; -DROP TABLE IF EXISTS `contest_team_template`; +DROP TABLE IF EXISTS `contest_template`; DROP TABLE IF EXISTS `contest_track`; DROP TABLE IF EXISTS `contest_sort`; DROP TABLE IF EXISTS `file`; diff --git a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java index f552106c..dc231671 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -40,8 +40,8 @@ import com.opus.opus.modules.contest.application.dto.request.VoteUpdateRequest; import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; import com.opus.opus.modules.contest.application.dto.response.ContestResponse; -import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; import com.opus.opus.modules.contest.application.dto.response.ContestSortResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; 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; @@ -171,6 +171,8 @@ void setUp() { parameterWithName("contestId").description("대회의 고유 ID") ), requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( numberFieldWithPath("maxVotesLimit", "최대 투표 개수") @@ -198,6 +200,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( dateTimeFieldWithPath("voteStartAt", "투표 시작일"), diff --git a/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java b/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java index c2681772..356f88ff 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/TeamApiDocsTest.java @@ -72,7 +72,8 @@ void setUp() { final Long teamId = 999L; when(teamQueryService.getPosterImage(any())) - .thenThrow(new FileException(FileExceptionType.NOT_EXISTS_MATCHING_IMAGE_ID)); // Assuming logic or specific TeamException. checking service... + .thenThrow(new FileException( + FileExceptionType.NOT_EXISTS_MATCHING_IMAGE_ID)); // Assuming logic or specific TeamException. checking service... // Wait, service uses teamConvenience.validateExistTeam(teamId). // I need to mock the service call to throw the exception that represents "Team Not Found" or "Image Not Found". // In TeamQueryService.getImage: @@ -150,7 +151,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ), requestParts( partWithName("image").description("등록할 포스터 이미지 (모든 이미지 형식 지원)") @@ -174,7 +176,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ) )); } @@ -262,7 +265,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ), requestParts( partWithName("image").description("등록할 썸네일 이미지 (모든 이미지 형식 지원)") @@ -286,7 +290,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ) )); } @@ -345,7 +350,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ), requestParts( partWithName("images").description("등록할 프리뷰 이미지 목록 (리스트)") @@ -373,7 +379,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ), requestFields( arrayFieldWithPath("imageIds", "삭제할 프리뷰 이미지 ID 리스트") @@ -407,7 +414,8 @@ void setUp() { parameterWithName("teamId").description("팀 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description(String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "(teamLeader/admin/teamMember)")) ), requestParts( partWithName("images").description("등록할 프리뷰 이미지") From 080d065d6397c42a54181ae3b34539b9543112f9 Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 02:41:47 +0900 Subject: [PATCH 13/24] =?UTF-8?q?chore=20:=20track,=20youTube=20=EB=84=A4?= =?UTF-8?q?=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest/api/ContestController.java | 2 +- .../application/ContestCommandService.java | 20 ++++++++-------- .../dto/request/ContestTemplateRequest.java | 4 ++-- .../dto/response/ContestTemplateResponse.java | 8 +++---- .../contest/domain/ContestTemplate.java | 24 +++++++++---------- src/main/resources/schema.sql | 24 +++++++++---------- 6 files changed, 41 insertions(+), 41 deletions(-) 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 ad12f0e1..6f7e688b 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 @@ -193,7 +193,7 @@ public ResponseEntity getVoteStatistics(@PathVari @GetMapping("/{contestId}/template") public ResponseEntity getContestTemplate(@PathVariable final Long contestId) { - ContestTemplateResponse response = contestQueryService.getContestTemplate(contestId); + final ContestTemplateResponse response = contestQueryService.getContestTemplate(contestId); return ResponseEntity.ok(response); } 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 1ba74273..17aa2c81 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 @@ -256,14 +256,14 @@ public void createTemplate(final Contest contest, final String categoryName) { ContestTemplate template = ContestTemplate.builder() .contest(contest) - .divisionRequired(settings.get("division")) + .trackRequired(settings.get("track")) .projectNameRequired(settings.get("projectName")) .teamNameRequired(settings.get("teamName")) .leaderRequired(settings.get("leader")) .teamMembersRequired(settings.get("teamMembers")) .professorRequired(settings.get("professor")) .githubPathRequired(settings.get("githubPath")) - .youtubePathRequired(settings.get("youtubePath")) + .youTubePathRequired(settings.get("youTubePath")) .productionPathRequired(settings.get("productionPath")) .overviewRequired(settings.get("overview")) .posterRequired(settings.get("poster")) @@ -278,9 +278,9 @@ public void updateContestTemplate(final Long contestId, final ContestTemplateReq final ContestTemplate template = contestConvenience.getValidateExistTemplate(contestId); template.updateTemplate( - request.divisionRequired(), request.projectNameRequired(), request.teamNameRequired(), + request.trackRequired(), request.projectNameRequired(), request.teamNameRequired(), request.leaderRequired(), request.teamMembersRequired(), request.professorRequired(), - request.githubPathRequired(), request.youtubePathRequired(), request.productionPathRequired(), + request.githubPathRequired(), request.youTubePathRequired(), request.productionPathRequired(), request.overviewRequired(), request.posterRequired(), request.imagesRequired() ); } @@ -289,42 +289,42 @@ private Map getDefaultTemplate(final String categoryName) { Map map = new HashMap<>(); if (categoryName.contains("창의융합")) { - map.put("division", true); + map.put("track", true); map.put("projectName", true); map.put("teamName", true); map.put("leader", true); map.put("teamMembers", true); map.put("professor", false); map.put("githubPath", true); - map.put("youtubePath", false); + map.put("youTubePath", false); map.put("productionPath", false); map.put("overview", true); map.put("poster", true); map.put("images", true); } else if (categoryName.contains("캡스톤")) { - map.put("division", true); + map.put("track", true); map.put("projectName", true); map.put("teamName", true); map.put("leader", true); map.put("teamMembers", true); map.put("professor", true); map.put("githubPath", true); - map.put("youtubePath", true); + map.put("youTubePath", true); map.put("productionPath", false); map.put("overview", true); map.put("poster", false); map.put("images", true); } else { - map.put("division", false); + map.put("track", false); map.put("projectName", false); map.put("teamName", false); map.put("leader", false); map.put("teamMembers", false); map.put("professor", false); map.put("githubPath", false); - map.put("youtubePath", false); + map.put("youTubePath", false); map.put("productionPath", false); map.put("overview", false); map.put("poster", false); diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java index 26dd5c91..1c5698c4 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/request/ContestTemplateRequest.java @@ -4,7 +4,7 @@ public record ContestTemplateRequest( @NotNull(message = "분과 필수 여부는 필수입니다.") - Boolean divisionRequired, + Boolean trackRequired, @NotNull(message = "프로젝트명 필수 여부는 필수입니다.") Boolean projectNameRequired, @NotNull(message = "팀명 필수 여부는 필수입니다.") @@ -18,7 +18,7 @@ public record ContestTemplateRequest( @NotNull(message = "GitHub 링크 필수 여부는 필수입니다.") Boolean githubPathRequired, @NotNull(message = "YouTube 링크 필수 여부는 필수입니다.") - Boolean youtubePathRequired, + Boolean youTubePathRequired, @NotNull(message = "배포 링크 필수 여부는 필수입니다.") Boolean productionPathRequired, @NotNull(message = "프로젝트 개요 필수 여부는 필수입니다.") diff --git a/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java index 9ae67256..cd2551bb 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java +++ b/src/main/java/com/opus/opus/modules/contest/application/dto/response/ContestTemplateResponse.java @@ -3,14 +3,14 @@ import com.opus.opus.modules.contest.domain.ContestTemplate; public record ContestTemplateResponse( - Boolean divisionRequired, + Boolean trackRequired, Boolean projectNameRequired, Boolean teamNameRequired, Boolean leaderRequired, Boolean teamMembersRequired, Boolean professorRequired, Boolean githubPathRequired, - Boolean youtubePathRequired, + Boolean youTubePathRequired, Boolean productionPathRequired, Boolean overviewRequired, Boolean posterRequired, @@ -18,14 +18,14 @@ public record ContestTemplateResponse( ) { public static ContestTemplateResponse from(final ContestTemplate template) { return new ContestTemplateResponse( - template.getDivisionRequired(), + template.getTrackRequired(), template.getProjectNameRequired(), template.getTeamNameRequired(), template.getLeaderRequired(), template.getTeamMembersRequired(), template.getProfessorRequired(), template.getGithubPathRequired(), - template.getYoutubePathRequired(), + template.getYouTubePathRequired(), template.getProductionPathRequired(), template.getOverviewRequired(), template.getPosterRequired(), diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java index cb85a4c3..db796908 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java @@ -32,8 +32,8 @@ public class ContestTemplate extends BaseEntity { @JoinColumn(name = "contest_id", nullable = false, unique = true) private Contest contest; - @Column(name = "division_required", nullable = false) - private Boolean divisionRequired; + @Column(name = "track_required", nullable = false) + private Boolean trackRequired; @Column(name = "project_name_required", nullable = false) private Boolean projectNameRequired; @@ -53,8 +53,8 @@ public class ContestTemplate extends BaseEntity { @Column(name = "github_path_required", nullable = false) private Boolean githubPathRequired; - @Column(name = "youtube_path_required", nullable = false) - private Boolean youtubePathRequired; + @Column(name = "youTube_path_required", nullable = false) + private Boolean youTubePathRequired; @Column(name = "production_path_required", nullable = false) private Boolean productionPathRequired; @@ -74,27 +74,27 @@ public class ContestTemplate extends BaseEntity { @Builder private ContestTemplate( final Contest contest, - final Boolean divisionRequired, + final Boolean trackRequired, final Boolean projectNameRequired, final Boolean teamNameRequired, final Boolean leaderRequired, final Boolean teamMembersRequired, final Boolean professorRequired, final Boolean githubPathRequired, - final Boolean youtubePathRequired, + final Boolean youTubePathRequired, final Boolean productionPathRequired, final Boolean overviewRequired, final Boolean posterRequired, final Boolean imagesRequired) { this.contest = contest; - this.divisionRequired = divisionRequired; + this.trackRequired = trackRequired; this.projectNameRequired = projectNameRequired; this.teamNameRequired = teamNameRequired; this.leaderRequired = leaderRequired; this.teamMembersRequired = teamMembersRequired; this.professorRequired = professorRequired; this.githubPathRequired = githubPathRequired; - this.youtubePathRequired = youtubePathRequired; + this.youTubePathRequired = youTubePathRequired; this.productionPathRequired = productionPathRequired; this.overviewRequired = overviewRequired; this.posterRequired = posterRequired; @@ -103,26 +103,26 @@ private ContestTemplate( } public void updateTemplate( - final Boolean divisionRequired, + final Boolean trackRequired, final Boolean projectNameRequired, final Boolean teamNameRequired, final Boolean leaderRequired, final Boolean teamMembersRequired, final Boolean professorRequired, final Boolean githubPathRequired, - final Boolean youtubePathRequired, + final Boolean youTubePathRequired, final Boolean productionPathRequired, final Boolean overviewRequired, final Boolean posterRequired, final Boolean imagesRequired) { - this.divisionRequired = divisionRequired; + this.trackRequired = trackRequired; this.projectNameRequired = projectNameRequired; this.teamNameRequired = teamNameRequired; this.leaderRequired = leaderRequired; this.teamMembersRequired = teamMembersRequired; this.professorRequired = professorRequired; this.githubPathRequired = githubPathRequired; - this.youtubePathRequired = youtubePathRequired; + this.youTubePathRequired = youTubePathRequired; this.productionPathRequired = productionPathRequired; this.overviewRequired = overviewRequired; this.posterRequired = posterRequired; diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index ae45aee1..7e9df118 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -57,18 +57,18 @@ CREATE TABLE `contest_template` ( `created_at` datetime(6) DEFAULT NULL, `updated_at` datetime(6) DEFAULT NULL, `contest_id` bigint NOT NULL, - `division` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `project_name` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `team_name` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `leader` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `team_members` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `professor` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `github_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `youtube_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `production_path` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `overview` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `poster` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, - `images` enum('REQUIRED','OPTIONAL','HIDDEN') NOT NULL, + `track_required` bit(1) NOT NULL, + `project_name_required` bit(1) NOT NULL, + `team_name_required` bit(1) NOT NULL, + `leader_required` bit(1) NOT NULL, + `team_members_required` bit(1) NOT NULL, + `professor_required` bit(1) NOT NULL, + `github_path_required` bit(1) NOT NULL, + `youTube_path_required` bit(1) NOT NULL, + `production_path_required` bit(1) NOT NULL, + `overview_required` bit(1) NOT NULL, + `poster_required` bit(1) NOT NULL, + `images_required` bit(1) NOT NULL, `is_deleted` bit(1) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_contest_team_template_contest_id` (`contest_id`) From 46d5b403c1e300ce19c599f5d09a53bf0211a7e7 Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 03:12:39 +0900 Subject: [PATCH 14/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../contest/domain/ContestTemplate.java | 26 ++--- src/main/resources/schema.sql | 2 +- .../opus/contest/ContestTemplateFixture.java | 25 +++++ .../ContestCommandServiceTest.java | 106 +++++++++++++++++- .../application/ContestQueryServiceTest.java | 39 ++++++- 5 files changed, 179 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/opus/opus/contest/ContestTemplateFixture.java diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java index db796908..deb52c7b 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java @@ -29,43 +29,43 @@ public class ContestTemplate extends BaseEntity { private Long id; @OneToOne(fetch = LAZY) - @JoinColumn(name = "contest_id", nullable = false, unique = true) + @JoinColumn(name = "contest_id", nullable = false) private Contest contest; - @Column(name = "track_required", nullable = false) + @Column(nullable = false) private Boolean trackRequired; - @Column(name = "project_name_required", nullable = false) + @Column(nullable = false) private Boolean projectNameRequired; - @Column(name = "team_name_required", nullable = false) + @Column(nullable = false) private Boolean teamNameRequired; - @Column(name = "leader_required", nullable = false) + @Column(nullable = false) private Boolean leaderRequired; - @Column(name = "team_members_required", nullable = false) + @Column(nullable = false) private Boolean teamMembersRequired; - @Column(name = "professor_required", nullable = false) + @Column(nullable = false) private Boolean professorRequired; - @Column(name = "github_path_required", nullable = false) + @Column(nullable = false) private Boolean githubPathRequired; - @Column(name = "youTube_path_required", nullable = false) + @Column(nullable = false) private Boolean youTubePathRequired; - @Column(name = "production_path_required", nullable = false) + @Column(nullable = false) private Boolean productionPathRequired; - @Column(name = "overview_required", nullable = false) + @Column(nullable = false) private Boolean overviewRequired; - @Column(name = "poster_required", nullable = false) + @Column(nullable = false) private Boolean posterRequired; - @Column(name = "images_required", nullable = false) + @Column(nullable = false) private Boolean imagesRequired; @Column(nullable = false) diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 7e9df118..f8e8f50e 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -64,7 +64,7 @@ CREATE TABLE `contest_template` ( `team_members_required` bit(1) NOT NULL, `professor_required` bit(1) NOT NULL, `github_path_required` bit(1) NOT NULL, - `youTube_path_required` bit(1) NOT NULL, + `you_tube_path_required` bit(1) NOT NULL, `production_path_required` bit(1) NOT NULL, `overview_required` bit(1) NOT NULL, `poster_required` bit(1) NOT NULL, diff --git a/src/test/java/com/opus/opus/contest/ContestTemplateFixture.java b/src/test/java/com/opus/opus/contest/ContestTemplateFixture.java new file mode 100644 index 00000000..9b9bbe08 --- /dev/null +++ b/src/test/java/com/opus/opus/contest/ContestTemplateFixture.java @@ -0,0 +1,25 @@ +package com.opus.opus.contest; + +import com.opus.opus.modules.contest.domain.Contest; +import com.opus.opus.modules.contest.domain.ContestTemplate; + +public class ContestTemplateFixture { + + public static ContestTemplate createContestTemplate(final Contest contest) { + return ContestTemplate.builder() + .contest(contest) + .trackRequired(true) + .projectNameRequired(true) + .teamNameRequired(true) + .leaderRequired(true) + .teamMembersRequired(true) + .professorRequired(true) + .githubPathRequired(true) + .youTubePathRequired(true) + .productionPathRequired(true) + .overviewRequired(true) + .posterRequired(true) + .imagesRequired(true) + .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 7e51c333..c1fa4674 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -12,20 +12,26 @@ 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.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; 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.ContestTemplateFixture; 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.ContestTemplateRequest; 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.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.domain.dao.ContestSortRepository; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.dao.TeamRepository; import java.time.LocalDateTime; @@ -37,18 +43,18 @@ public class ContestCommandServiceTest extends IntegrationTest { + private static final Integer MAX_VOTES_LIMIT = 5; @Autowired private ContestCommandService contestCommandService; - @Autowired private ContestRepository contestRepository; @Autowired private ContestSortRepository contestSortRepository; @Autowired private TeamRepository teamRepository; - + @Autowired + private ContestTemplateRepository contestTemplateRepository; private Contest contest; - private static final Integer MAX_VOTES_LIMIT = 5; @BeforeEach void setUp() { @@ -221,7 +227,7 @@ void setUp() { 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)); + teamRepository.save(createTeamWithContestIdAndItemOrder(contest.getId(), 3)); final List requests = List.of(new ContestSortCustomRequest(teamOne.getId(), 2), new ContestSortCustomRequest(teamTwo.getId(), 1)); @@ -244,4 +250,96 @@ void setUp() { contestCommandService.updateContestSortCustom(contest.getId(), requests); }).isInstanceOf(ContestException.class).hasMessage(INVALID_ITEM_ORDER.errorMessage()); } + + @Test + @DisplayName("[성공] 대회 템플릿을 수정한다.") + void 대회_템플릿을_수정한다() { + // given + contestTemplateRepository.save(ContestTemplateFixture.createContestTemplate(contest)); + final ContestTemplateRequest request = new ContestTemplateRequest( + false, false, false, false, false, false, + false, false, false, false, false, false + ); + + // when + contestCommandService.updateContestTemplate(contest.getId(), request); + + // then + final ContestTemplate updatedTemplate = contestTemplateRepository.findByContestId(contest.getId()) + .orElseThrow(); + assertThat(updatedTemplate.getTrackRequired()).isFalse(); + assertThat(updatedTemplate.getProjectNameRequired()).isFalse(); + assertThat(updatedTemplate.getTeamNameRequired()).isFalse(); + assertThat(updatedTemplate.getLeaderRequired()).isFalse(); + assertThat(updatedTemplate.getTeamMembersRequired()).isFalse(); + assertThat(updatedTemplate.getProfessorRequired()).isFalse(); + assertThat(updatedTemplate.getGithubPathRequired()).isFalse(); + assertThat(updatedTemplate.getYouTubePathRequired()).isFalse(); + assertThat(updatedTemplate.getProductionPathRequired()).isFalse(); + assertThat(updatedTemplate.getOverviewRequired()).isFalse(); + assertThat(updatedTemplate.getPosterRequired()).isFalse(); + assertThat(updatedTemplate.getImagesRequired()).isFalse(); + } + + @Test + @DisplayName("[성공] 대회 템플릿 정보가 존재하지 않으면 수정에 실패한다.") + void 대회_템플릿_정보가_존재하지_않으면_수정에_실패한다() { + final ContestTemplateRequest request = new ContestTemplateRequest( + false, false, false, false, false, false, + false, false, false, false, false, false + ); + + assertThatThrownBy(() -> { + contestCommandService.updateContestTemplate(contest.getId(), request); + }).isInstanceOf(ContestTeamTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); + } + + @Test + @DisplayName("[성공] 창의융합 카테고리로 템플릿을 생성한다.") + void 창의융합_카테고리로_템플릿을_생성한다() { + // given + final String categoryName = "창의융합공학"; + + // when + contestCommandService.createTemplate(contest, categoryName); + + // then + final ContestTemplate template = contestTemplateRepository.findByContestId(contest.getId()).orElseThrow(); + assertThat(template.getTrackRequired()).isTrue(); + assertThat(template.getProfessorRequired()).isFalse(); + assertThat(template.getYouTubePathRequired()).isFalse(); + } + + @Test + @DisplayName("[성공] 캡스톤 카테고리로 템플릿을 생성한다.") + void 캡스톤_카테고리로_템플릿을_생성한다() { + // given + final String categoryName = "캡스톤디자인"; + + // when + contestCommandService.createTemplate(contest, categoryName); + + // then + final ContestTemplate template = contestTemplateRepository.findByContestId(contest.getId()).orElseThrow(); + assertThat(template.getTrackRequired()).isTrue(); + assertThat(template.getProfessorRequired()).isTrue(); + assertThat(template.getYouTubePathRequired()).isTrue(); + assertThat(template.getPosterRequired()).isFalse(); + } + + @Test + @DisplayName("[성공] 기본 카테고리로 템플릿을 생성한다.") + void 기본_카테고리로_템플릿을_생성한다() { + // given + final String categoryName = "기타"; + + // when + contestCommandService.createTemplate(contest, categoryName); + + // then + final ContestTemplate template = contestTemplateRepository.findByContestId(contest.getId()).orElseThrow(); + assertThat(template.getTrackRequired()).isFalse(); + assertThat(template.getProjectNameRequired()).isFalse(); + assertThat(template.getImagesRequired()).isFalse(); + } } diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java index 335a5f7e..79c871a8 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -1,17 +1,22 @@ package com.opus.opus.contest.application; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; 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.contest.ContestTemplateFixture; import com.opus.opus.helper.IntegrationTest; import com.opus.opus.modules.contest.application.ContestQueryService; +import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse; 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.domain.Contest; import com.opus.opus.modules.contest.domain.dao.ContestRepository; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -26,6 +31,9 @@ public class ContestQueryServiceTest extends IntegrationTest { @Autowired private ContestRepository contestRepository; + @Autowired + private ContestTemplateRepository contestTemplateRepository; + private Contest contest; @BeforeEach @@ -74,4 +82,33 @@ void setUp() { contestQueryService.getMaxVotesLimit(invalidContestId); }).isInstanceOf(ContestException.class).hasMessage(NOT_FOUND_CONTEST.errorMessage()); } -} \ No newline at end of file + + @Test + @DisplayName("[성공] 대회 템플릿를 조회한다.") + void 대회_템플릿를_조회한다() { + contestTemplateRepository.save(ContestTemplateFixture.createContestTemplate(contest)); + + final ContestTemplateResponse response = contestQueryService.getContestTemplate(contest.getId()); + + assertThat(response.trackRequired()).isTrue(); + assertThat(response.projectNameRequired()).isTrue(); + assertThat(response.teamNameRequired()).isTrue(); + assertThat(response.leaderRequired()).isTrue(); + assertThat(response.teamMembersRequired()).isTrue(); + assertThat(response.professorRequired()).isTrue(); + assertThat(response.githubPathRequired()).isTrue(); + assertThat(response.youTubePathRequired()).isTrue(); + assertThat(response.productionPathRequired()).isTrue(); + assertThat(response.overviewRequired()).isTrue(); + assertThat(response.posterRequired()).isTrue(); + assertThat(response.imagesRequired()).isTrue(); + } + + @Test + @DisplayName("[실패] 대회 템플릿이 존재하지 않으면 예외가 발생한다.") + void 대회_템플릿이_존재하지_않으면_예외가_발생한다() { + assertThatThrownBy(() -> { + contestQueryService.getContestTemplate(contest.getId()); + }).isInstanceOf(ContestTeamTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); + } +} From 642154452e65e6494386d6ca29fcc96ad029b930 Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 03:20:58 +0900 Subject: [PATCH 15/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20restDocTest=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restdocs/docs/ContestApiDocsTest.java | 120 +++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) 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 dc231671..fccdd548 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -36,11 +36,13 @@ 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.ContestTemplateRequest; 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.ContestRankingResponse; 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.ContestTemplateResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; import com.opus.opus.modules.contest.application.dto.response.ContestVotesLimitResponse; import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; @@ -547,7 +549,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), responseFields( stringFieldWithPath("currentMode", "현재 적용되어 있는 모드 정보") @@ -782,4 +785,119 @@ void setUp() { ) )); } + + @Test + @DisplayName("[성공] 대회의 템플릿을 조회한다.") + void 대회의_템플릿_설정을_조회한다() throws Exception { + final ContestTemplateResponse response = new ContestTemplateResponse( + true, true, true, true, true, true, + true, true, true, true, true, true + ); + + when(contestQueryService.getContestTemplate(any())).thenReturn(response); + + mockMvc.perform(get("/contests/{contestId}/template", 1)) + .andExpect(status().isOk()) + .andDo(document("get-contest-template", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + responseFields( + booleanFieldWithPath("trackRequired", "트랙/분과 필수 여부"), + booleanFieldWithPath("projectNameRequired", "프로젝트명 필수 여부"), + booleanFieldWithPath("teamNameRequired", "팀명 필수 여부"), + booleanFieldWithPath("leaderRequired", "팀장 필수 여부"), + booleanFieldWithPath("teamMembersRequired", "팀원 필수 여부"), + booleanFieldWithPath("professorRequired", "지도교수 필수 여부"), + booleanFieldWithPath("githubPathRequired", "GitHub 링크 필수 여부"), + booleanFieldWithPath("youTubePathRequired", "YouTube 링크 필수 여부"), + booleanFieldWithPath("productionPathRequired", "배포 링크 필수 여부"), + booleanFieldWithPath("overviewRequired", "프로젝트 개요 필수 여부"), + booleanFieldWithPath("posterRequired", "포스터 필수 여부"), + booleanFieldWithPath("imagesRequired", "이미지 필수 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 템플릿 조회 시 404 에러를 반환한다.") + void 존재하지_않는_대회의_템플릿_조회_시_에러를_반환한다() throws Exception { + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(contestQueryService) + .getContestTemplate(any()); + + mockMvc.perform(get("/contests/{contestId}/template", 999)) + .andExpect(status().isNotFound()) + .andDo(document("get-contest-template-fail-not-found", + pathParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ) + )); + } + + @Test + @DisplayName("[성공] 대회의 템플릿을 수정한다.") + void 대회의_템플릿_설정을_수정한다() throws Exception { + final ContestTemplateRequest request = new ContestTemplateRequest( + false, false, false, false, false, false, + false, false, false, false, false, false + ); + + doNothing().when(contestCommandService).updateContestTemplate(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/template", 1) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNoContent()) + .andDo(document("update-contest-template", + pathParameters( + parameterWithName("contestId").description("대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ), + requestFields( + booleanFieldWithPath("trackRequired", "트랙/분과 필수 여부"), + booleanFieldWithPath("projectNameRequired", "프로젝트명 필수 여부"), + booleanFieldWithPath("teamNameRequired", "팀명 필수 여부"), + booleanFieldWithPath("leaderRequired", "팀장 필수 여부"), + booleanFieldWithPath("teamMembersRequired", "팀원 필수 여부"), + booleanFieldWithPath("professorRequired", "지도교수 필수 여부"), + booleanFieldWithPath("githubPathRequired", "GitHub 링크 필수 여부"), + booleanFieldWithPath("youTubePathRequired", "YouTube 링크 필수 여부"), + booleanFieldWithPath("productionPathRequired", "배포 링크 필수 여부"), + booleanFieldWithPath("overviewRequired", "프로젝트 개요 필수 여부"), + booleanFieldWithPath("posterRequired", "포스터 필수 여부"), + booleanFieldWithPath("imagesRequired", "이미지 필수 여부") + ) + )); + } + + @Test + @DisplayName("[실패] 존재하지 않는 대회의 템플릿 수정 시 404 에러를 반환한다.") + void 존재하지_않는_대회의_템플릿_수정_시_에러를_반환한다() throws Exception { + final ContestTemplateRequest request = new ContestTemplateRequest( + false, false, false, false, false, false, + false, false, false, false, false, false + ); + + willThrow(new ContestException(NOT_FOUND_CONTEST)) + .given(contestCommandService) + .updateContestTemplate(any(), any()); + + mockMvc.perform(put("/contests/{contestId}/template", 999) + .header(HttpHeaders.AUTHORIZATION, ADMIN_TOKEN) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andDo(document("update-contest-template-fail-not-found", + pathParameters( + parameterWithName("contestId").description("존재하지 않는 대회 ID") + ), + requestHeaders( + headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + ) + )); + } } From e1cd5b5c9b52db631fa54adab7c9e574d5c23b15 Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 03:24:01 +0900 Subject: [PATCH 16/24] =?UTF-8?q?feat=20:=20=EB=8C=80=ED=9A=8C=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20adoc=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/opus/opus/docs/asciidoc/contest.adoc | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 09c43463..6dfd232c 100644 --- a/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc +++ b/src/main/java/com/opus/opus/docs/asciidoc/contest.adoc @@ -436,3 +436,58 @@ include::{snippets}/get-vote-statistics-fail-not-found/http-request.adoc[] include::{snippets}/get-vote-statistics-fail-not-found/http-response.adoc[] ==== + +== 대회 팀 템플릿 설정 관리 + +=== `GET`: 대회의 템플릿 조회 + +.Path Parameters +include::{snippets}/get-contest-template/path-parameters.adoc[] + +.HTTP Request +include::{snippets}/get-contest-template/http-request.adoc[] + +.HTTP Response +include::{snippets}/get-contest-template/http-response.adoc[] + +.Response Fields +include::{snippets}/get-contest-template/response-fields.adoc[] + +==== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 + +[%collapsible] +==== +include::{snippets}/get-contest-template-fail-not-found/path-parameters.adoc[] +include::{snippets}/get-contest-template-fail-not-found/http-request.adoc[] +include::{snippets}/get-contest-template-fail-not-found/http-response.adoc[] +==== + +=== `PUT`: 대회의 템플릿 수정 + +.Path Parameters +include::{snippets}/update-contest-template/path-parameters.adoc[] + +.Request Headers +include::{snippets}/update-contest-template/request-headers.adoc[] + +.Request Fields +include::{snippets}/update-contest-template/request-fields.adoc[] + +.HTTP Request +include::{snippets}/update-contest-template/http-request.adoc[] + +.HTTP Response +include::{snippets}/update-contest-template/http-response.adoc[] + +==== ⚠️ 실패 케이스 + +.❌ Case 1: 존재하지 않는 대회 + +[%collapsible] +==== +include::{snippets}/update-contest-template-fail-not-found/path-parameters.adoc[] +include::{snippets}/update-contest-template-fail-not-found/http-request.adoc[] +include::{snippets}/update-contest-template-fail-not-found/http-response.adoc[] +==== From 414cd21163d71148d68c6110fe496ca9a655edab Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 03:27:02 +0900 Subject: [PATCH 17/24] =?UTF-8?q?refactor=20:=20ContestTemplate=20?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B0=8D=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/convenience/ContestConvenience.java | 10 +++++----- ...ateException.java => ContestTemplateException.java} | 9 ++++----- ...tionType.java => ContestTemplateExceptionType.java} | 8 ++++---- .../contest/application/ContestCommandServiceTest.java | 6 +++--- .../contest/application/ContestQueryServiceTest.java | 6 +++--- 5 files changed, 19 insertions(+), 20 deletions(-) rename src/main/java/com/opus/opus/modules/contest/exception/{ContestTeamTemplateException.java => ContestTemplateException.java} (54%) rename src/main/java/com/opus/opus/modules/contest/exception/{ContestTeamTemplateExceptionType.java => ContestTemplateExceptionType.java} (65%) 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 5892c1e7..20c10ff0 100644 --- a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestConvenience.java @@ -1,20 +1,20 @@ package com.opus.opus.modules.contest.application.convenience; -import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CATEGORY_HAS_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.CONTEST_NAME_ALREADY_EXIST; +import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; -import static org.springframework.transaction.annotation.Propagation.MANDATORY; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; -import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; +import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; +import static org.springframework.transaction.annotation.Propagation.MANDATORY; import com.opus.opus.modules.contest.domain.Contest; import com.opus.opus.modules.contest.domain.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; -import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; +import com.opus.opus.modules.contest.exception.ContestTemplateException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -76,6 +76,6 @@ public void validateVotingPeriod(final Contest contest) { public ContestTemplate getValidateExistTemplate(final Long contestId) { return contestTemplateRepository.findByContestId(contestId) - .orElseThrow(() -> new ContestTeamTemplateException(NOT_FOUND_TEMPLATE)); + .orElseThrow(() -> new ContestTemplateException(NOT_FOUND_TEMPLATE)); } } diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateException.java similarity index 54% rename from src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java rename to src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateException.java index dadc8cd3..d583ebe3 100644 --- a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateException.java +++ b/src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateException.java @@ -3,15 +3,15 @@ import com.opus.opus.global.base.BaseException; import com.opus.opus.global.base.BaseExceptionType; -public class ContestTeamTemplateException extends BaseException { - private final ContestTeamTemplateExceptionType exceptionType; +public class ContestTemplateException extends BaseException { + private final ContestTemplateExceptionType exceptionType; - public ContestTeamTemplateException(final ContestTeamTemplateExceptionType exceptionType) { + public ContestTemplateException(final ContestTemplateExceptionType exceptionType) { super(exceptionType.errorMessage()); this.exceptionType = exceptionType; } - public ContestTeamTemplateException(final ContestTeamTemplateExceptionType exceptionType, final String message) { + public ContestTemplateException(final ContestTemplateExceptionType exceptionType, final String message) { super(message); this.exceptionType = exceptionType; } @@ -21,4 +21,3 @@ public BaseExceptionType exceptionType() { return exceptionType; } } - diff --git a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java b/src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateExceptionType.java similarity index 65% rename from src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java rename to src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateExceptionType.java index 0f49c438..7ee8c9ee 100644 --- a/src/main/java/com/opus/opus/modules/contest/exception/ContestTeamTemplateExceptionType.java +++ b/src/main/java/com/opus/opus/modules/contest/exception/ContestTemplateExceptionType.java @@ -3,14 +3,14 @@ import com.opus.opus.global.base.BaseExceptionType; import org.springframework.http.HttpStatus; -public enum ContestTeamTemplateExceptionType implements BaseExceptionType { +public enum ContestTemplateExceptionType implements BaseExceptionType { NOT_FOUND_TEMPLATE(HttpStatus.NOT_FOUND, "템플릿을 찾을 수 없습니다."), - INVALID_TEMPLATE_FIELD_TYPE(HttpStatus.BAD_REQUEST, "TemplateFieldType은 REQUIRED, OPTIONAL, HIDDEN 중 하나입니다."); - + ; + private final HttpStatus httpStatus; private final String errorMessage; - ContestTeamTemplateExceptionType(final HttpStatus httpStatus, final String errorMessage) { + ContestTemplateExceptionType(final HttpStatus httpStatus, final String errorMessage) { this.httpStatus = httpStatus; this.errorMessage = errorMessage; } 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 c1fa4674..c41267ad 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -12,7 +12,7 @@ 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.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; +import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; import static com.opus.opus.team.TeamFixture.createTeamWithContestIdAndItemOrder; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -31,7 +31,7 @@ import com.opus.opus.modules.contest.domain.dao.ContestSortRepository; import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; -import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; +import com.opus.opus.modules.contest.exception.ContestTemplateException; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.dao.TeamRepository; import java.time.LocalDateTime; @@ -291,7 +291,7 @@ void setUp() { assertThatThrownBy(() -> { contestCommandService.updateContestTemplate(contest.getId(), request); - }).isInstanceOf(ContestTeamTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); + }).isInstanceOf(ContestTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); } @Test diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java index 79c871a8..2a7ddcf1 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -1,7 +1,7 @@ package com.opus.opus.contest.application; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; -import static com.opus.opus.modules.contest.exception.ContestTeamTemplateExceptionType.NOT_FOUND_TEMPLATE; +import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -16,7 +16,7 @@ import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; -import com.opus.opus.modules.contest.exception.ContestTeamTemplateException; +import com.opus.opus.modules.contest.exception.ContestTemplateException; import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -109,6 +109,6 @@ void setUp() { void 대회_템플릿이_존재하지_않으면_예외가_발생한다() { assertThatThrownBy(() -> { contestQueryService.getContestTemplate(contest.getId()); - }).isInstanceOf(ContestTeamTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); + }).isInstanceOf(ContestTemplateException.class).hasMessage(NOT_FOUND_TEMPLATE.errorMessage()); } } From 0c988b86762672c2e0b40ef2a3baccb2f479fed5 Mon Sep 17 00:00:00 2001 From: myeowon Date: Mon, 23 Feb 2026 03:36:50 +0900 Subject: [PATCH 18/24] =?UTF-8?q?feat=20:=20ContestConvenience=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ContestTemplateConvenience=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ContestCommandService.java | 4 +++- .../convenience/ContestConvenience.java | 10 -------- .../ContestTemplateConvenience.java | 23 +++++++++++++++++++ 3 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTemplateConvenience.java 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 17aa2c81..839b9e32 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 @@ -23,6 +23,7 @@ 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.convenience.ContestTemplateConvenience; 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; @@ -68,6 +69,7 @@ public class ContestCommandService { private final ContestCategoryConvenience contestCategoryConvenience; private final ContestSortConvenience contestSortConvenience; private final TeamConvenience teamConvenience; + private final ContestTemplateConvenience contestTemplateConvenience; private final FileStorageUtil fileStorageUtil; @@ -275,7 +277,7 @@ public void createTemplate(final Contest contest, final String categoryName) { public void updateContestTemplate(final Long contestId, final ContestTemplateRequest request) { contestConvenience.getValidateExistContest(contestId); - final ContestTemplate template = contestConvenience.getValidateExistTemplate(contestId); + final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId); template.updateTemplate( request.trackRequired(), request.projectNameRequired(), request.teamNameRequired(), 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 20c10ff0..c62a65c4 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 @@ -6,15 +6,11 @@ import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_ALLOWED_DURING_VOTING_PERIOD; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_VOTE_PERIOD_NOW; -import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; import static org.springframework.transaction.annotation.Propagation.MANDATORY; import com.opus.opus.modules.contest.domain.Contest; -import com.opus.opus.modules.contest.domain.ContestTemplate; import com.opus.opus.modules.contest.domain.dao.ContestRepository; -import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; -import com.opus.opus.modules.contest.exception.ContestTemplateException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -26,7 +22,6 @@ public class ContestConvenience { private final ContestRepository contestRepository; - private final ContestTemplateRepository contestTemplateRepository; public Contest getValidateExistContest(final Long contestId) { return contestRepository.findById(contestId).orElseThrow(() -> new ContestException(NOT_FOUND_CONTEST)); @@ -73,9 +68,4 @@ public void validateVotingPeriod(final Contest contest) { throw new ContestException(NOT_VOTE_PERIOD_NOW); } } - - public ContestTemplate getValidateExistTemplate(final Long contestId) { - return contestTemplateRepository.findByContestId(contestId) - .orElseThrow(() -> new ContestTemplateException(NOT_FOUND_TEMPLATE)); - } } diff --git a/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTemplateConvenience.java b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTemplateConvenience.java new file mode 100644 index 00000000..24cfceb6 --- /dev/null +++ b/src/main/java/com/opus/opus/modules/contest/application/convenience/ContestTemplateConvenience.java @@ -0,0 +1,23 @@ +package com.opus.opus.modules.contest.application.convenience; + +import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; + +import com.opus.opus.modules.contest.domain.ContestTemplate; +import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; +import com.opus.opus.modules.contest.exception.ContestTemplateException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContestTemplateConvenience { + + private final ContestTemplateRepository contestTemplateRepository; + + public ContestTemplate getValidateExistTemplate(final Long contestId) { + return contestTemplateRepository.findByContestId(contestId) + .orElseThrow(() -> new ContestTemplateException(NOT_FOUND_TEMPLATE)); + } +} From a01e93656a5d2c7cc94f12caaea988184878c0cd Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 26 Feb 2026 21:36:09 +0900 Subject: [PATCH 19/24] =?UTF-8?q?fix=20:=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ContestQueryService.java | 55 ++++++++++--------- .../application/ContestQueryServiceTest.java | 2 - 2 files changed, 28 insertions(+), 29 deletions(-) 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 de7477f6..2cee194b 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 @@ -10,25 +10,24 @@ 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.convenience.ContestTemplateConvenience; import com.opus.opus.modules.contest.application.dto.response.ContestCurrentResponse; import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; 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.ContestVoteLogResponse; import com.opus.opus.modules.contest.application.dto.response.ContestSubmissionResponse; -import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; -import com.opus.opus.modules.contest.application.dto.response.VotePeriodResponse; import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestVoteLogResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; 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.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.ContestTrack; import com.opus.opus.modules.contest.domain.ContestTemplate; +import com.opus.opus.modules.contest.domain.ContestTrack; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.domain.dao.ContestTrackRepository; -import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; 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; @@ -37,19 +36,17 @@ import com.opus.opus.modules.team.application.convenience.TeamConvenience; import com.opus.opus.modules.team.application.convenience.TeamVoteConvenience; import com.opus.opus.modules.team.application.dto.ImageResponse; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.TeamVote; -import java.util.List; -import java.util.Map; -import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; import com.opus.opus.modules.team.domain.dao.TeamRankingResult; import com.opus.opus.modules.team.domain.dao.TeamRepository; import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; import com.opus.opus.modules.team.domain.dao.VoteStatisticsResult; import java.util.ArrayList; - +import java.util.List; +import java.util.Map; import java.util.stream.Collectors; - import lombok.RequiredArgsConstructor; import org.antlr.v4.runtime.misc.Pair; import org.springframework.core.io.Resource; @@ -71,16 +68,35 @@ public class ContestQueryService { private final TeamRepository teamRepository; private final TeamVoteRepository teamVoteRepository; private final ContestTrackRepository contestTrackRepository; - private final ContestTemplateRepository contestTemplateRepository; private final ContestCategoryConvenience contestCategoryConvenience; private final ContestConvenience contestConvenience; private final ContestSortConvenience contestSortConvenience; + private final ContestTemplateConvenience contestTemplateConvenience; private final TeamConvenience teamConvenience; private final TeamVoteConvenience teamVoteConvenience; private final MemberConvenience memberConvenience; private final FileConvenience fileConvenience; + private static List applyDenseRanking(List votesPerTeam) { + List responseList = new ArrayList<>(); + int curRank = 0; // 현재 순위 + long prevCount = -1; // 이전 팀 투표 수 + for (TeamRankingResult result : votesPerTeam) { + // 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지 + if (prevCount != result.voteCount()) { + curRank++; + } + prevCount = result.voteCount(); + + responseList.add( + new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(), + result.trackName(), result.voteCount())); + } + + return responseList; + } + public ImageResponse getContestBanner(final Long contestId) { contestConvenience.getValidateExistContest(contestId); final File findBanner = fileConvenience.findByReferenceIdAndReferenceTypeAndImageType(contestId, CONTEST, @@ -205,21 +221,6 @@ public List getTeamSubmissions(Long contestId) { .toList(); } - private static List applyDenseRanking(List votesPerTeam) { - List responseList = new ArrayList<>(); - int curRank = 0; // 현재 순위 - long prevCount = -1; // 이전 팀 투표 수 - for (TeamRankingResult result : votesPerTeam) { - // 이전 팀과 투표 수가 다르면 순위 증가, 같으면 순위 유지 - if (prevCount != result.voteCount()) curRank++; - prevCount = result.voteCount(); - - responseList.add(new ContestRankingResponse(curRank, result.teamId(), result.teamName(), result.projectName(), result.trackName(), result.voteCount())); - } - - return responseList; - } - private void checkImageConverted(final File findFile) { if (!findFile.getIsWebpConverted()) { throw new FileException(NOT_WEBP_CONVERTED); @@ -229,7 +230,7 @@ private void checkImageConverted(final File findFile) { public ContestTemplateResponse getContestTemplate(final Long contestId) { contestConvenience.getValidateExistContest(contestId); - final ContestTemplate template = contestConvenience.getValidateExistTemplate(contestId); + final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId); return ContestTemplateResponse.from(template); } } diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java index 20d7fa16..e83dbefe 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -287,8 +287,6 @@ void setUp() { .isInstanceOf(ContestException.class) .hasMessage(NOT_FOUND_CONTEST.errorMessage()); } -} - @Test @DisplayName("[성공] 대회 템플릿를 조회한다.") From 4c72377b4bef303f0b22e503b670721b98a8b05a Mon Sep 17 00:00:00 2001 From: myeowon <135775039+myeowon@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:46:01 +0900 Subject: [PATCH 20/24] Update src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../opus/modules/contest/application/ContestCommandService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 839b9e32..d80e352b 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 @@ -256,7 +256,7 @@ private void applyCustomSortToTeams(final List request public void createTemplate(final Contest contest, final String categoryName) { final Map settings = getDefaultTemplate(categoryName); - ContestTemplate template = ContestTemplate.builder() + final ContestTemplate template = ContestTemplate.builder() .contest(contest) .trackRequired(settings.get("track")) .projectNameRequired(settings.get("projectName")) From f00bd2db2d6f2970b21a69c8b9657ae352db26c4 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 26 Feb 2026 22:34:21 +0900 Subject: [PATCH 21/24] =?UTF-8?q?fix=20:=20categoryName=20null=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/contest/application/ContestCommandService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 839b9e32..fa0f5665 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 @@ -290,7 +290,7 @@ public void updateContestTemplate(final Long contestId, final ContestTemplateReq private Map getDefaultTemplate(final String categoryName) { Map map = new HashMap<>(); - if (categoryName.contains("창의융합")) { + if (categoryName != null && categoryName.contains("창의융합")) { map.put("track", true); map.put("projectName", true); map.put("teamName", true); @@ -304,7 +304,7 @@ private Map getDefaultTemplate(final String categoryName) { map.put("poster", true); map.put("images", true); - } else if (categoryName.contains("캡스톤")) { + } else if (categoryName != null && categoryName.contains("캡스톤")) { map.put("track", true); map.put("projectName", true); map.put("teamName", true); From 358fcb3855488b31b4b505fb22a35c57fc530455 Mon Sep 17 00:00:00 2001 From: myeowon Date: Thu, 26 Feb 2026 22:46:39 +0900 Subject: [PATCH 22/24] =?UTF-8?q?fix=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ContestCommandServiceTest.java | 2 +- .../application/ContestQueryServiceTest.java | 18 +++++++------- .../restdocs/docs/ContestApiDocsTest.java | 24 ++++++++++++------- 3 files changed, 26 insertions(+), 18 deletions(-) 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 c41267ad..4c26113f 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestCommandServiceTest.java @@ -282,7 +282,7 @@ void setUp() { } @Test - @DisplayName("[성공] 대회 템플릿 정보가 존재하지 않으면 수정에 실패한다.") + @DisplayName("[실패] 대회 템플릿 정보가 존재하지 않으면 수정에 실패한다.") void 대회_템플릿_정보가_존재하지_않으면_수정에_실패한다() { final ContestTemplateRequest request = new ContestTemplateRequest( false, false, false, false, false, false, diff --git a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java index e83dbefe..9d30dfbe 100644 --- a/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java +++ b/src/test/java/com/opus/opus/contest/application/ContestQueryServiceTest.java @@ -2,10 +2,10 @@ import static com.opus.opus.member.MemberFixture.createMemberWithUniqueNum; import static com.opus.opus.modules.contest.exception.ContestExceptionType.NOT_FOUND_CONTEST; +import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; import static com.opus.opus.team.TeamFixture.createTeamWithContestId; import static com.opus.opus.team.TeamVoteFixture.createTeamVote; import static java.time.LocalDateTime.now; -import static com.opus.opus.modules.contest.exception.ContestTemplateExceptionType.NOT_FOUND_TEMPLATE; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -14,27 +14,26 @@ 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.ContestVoteLogResponse; import com.opus.opus.modules.contest.application.dto.response.ContestRankingResponse; import com.opus.opus.modules.contest.application.dto.response.ContestSubmissionResponse; -import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; import com.opus.opus.modules.contest.application.dto.response.ContestTemplateResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestVoteLogResponse; +import com.opus.opus.modules.contest.application.dto.response.ContestVoteStatisticsResponse; 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.domain.Contest; import com.opus.opus.modules.contest.domain.dao.ContestRepository; import com.opus.opus.modules.contest.domain.dao.ContestTemplateRepository; import com.opus.opus.modules.contest.exception.ContestException; +import com.opus.opus.modules.contest.exception.ContestTemplateException; import com.opus.opus.modules.member.domain.Member; import com.opus.opus.modules.member.domain.dao.MemberRepository; +import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; import com.opus.opus.modules.team.domain.Team; import com.opus.opus.modules.team.domain.dao.TeamRepository; import com.opus.opus.modules.team.domain.dao.TeamVoteRepository; -import com.opus.opus.modules.team.application.dto.response.MemberVoteCountResponse; import com.opus.opus.team.TeamFixture; import com.opus.opus.team.TeamVoteFixture; - -import com.opus.opus.modules.contest.exception.ContestTemplateException; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -273,7 +272,8 @@ void setUp() { void 팀이_없는_대회는_빈_리스트를_반환한다() { final Contest emptyContest = contestRepository.save(ContestFixture.createContest()); - final List responseList = contestQueryService.getTeamSubmissions(emptyContest.getId()); + final List responseList = contestQueryService.getTeamSubmissions( + emptyContest.getId()); assertThat(responseList).isEmpty(); } @@ -289,8 +289,8 @@ void setUp() { } @Test - @DisplayName("[성공] 대회 템플릿를 조회한다.") - void 대회_템플릿를_조회한다() { + @DisplayName("[성공] 대회 템플릿을 조회한다.") + void 대회_템플릿을_조회한다() { contestTemplateRepository.save(ContestTemplateFixture.createContestTemplate(contest)); final ContestTemplateResponse response = contestQueryService.getContestTemplate(contest.getId()); 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 9ffdf4d6..5f5f1fa6 100644 --- a/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java +++ b/src/test/java/com/opus/opus/restdocs/docs/ContestApiDocsTest.java @@ -413,7 +413,8 @@ void setUp() { .andExpect(status().isOk()) .andDo(document("create-contest", requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( stringFieldWithPath("contestName", "대회 이름"), @@ -466,7 +467,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( stringFieldWithPath("contestName", "대회 이름"), @@ -488,7 +490,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ) )); } @@ -535,7 +538,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( stringFieldWithPath("mode", "수정할 대회 정렬 모드") @@ -557,7 +561,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), responseFields( stringFieldWithPath("currentMode", "현재 적용되어 있는 모드 정보") @@ -714,7 +719,8 @@ void setUp() { parameterWithName("size").description("페이지 크기").optional() ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), responseFields( arrayFieldWithPath("content[]", "투표 로그 목록 (최신순)"), @@ -957,7 +963,8 @@ void setUp() { parameterWithName("contestId").description("대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ), requestFields( booleanFieldWithPath("trackRequired", "트랙/분과 필수 여부"), @@ -998,7 +1005,8 @@ void setUp() { parameterWithName("contestId").description("존재하지 않는 대회 ID") ), requestHeaders( - headerWithName(HttpHeaders.AUTHORIZATION).description("Bearer {accessToken} (관리자)") + headerWithName(HttpHeaders.AUTHORIZATION).description( + String.format(authorizationHeaderDescription, "admin")) ) )); } From ae0a3500ca6732e16b4c1058afb64b77c52675b8 Mon Sep 17 00:00:00 2001 From: myeowon <135775039+myeowon@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:45:11 +0900 Subject: [PATCH 23/24] Update src/main/java/com/opus/opus/modules/contest/application/ContestCommandService.java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 김태윤 <77539625+pykido@users.noreply.github.com> --- .../opus/modules/contest/application/ContestCommandService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f641f654..26f9d5f7 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 @@ -276,7 +276,7 @@ public void createTemplate(final Contest contest, final String categoryName) { } public void updateContestTemplate(final Long contestId, final ContestTemplateRequest request) { - contestConvenience.getValidateExistContest(contestId); + contestConvenience.validateExistContest(contestId); final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId); template.updateTemplate( From 579b0ef94c985ecbe2522d797580ad1653daa13b Mon Sep 17 00:00:00 2001 From: myeowon Date: Fri, 27 Feb 2026 15:05:24 +0900 Subject: [PATCH 24/24] =?UTF-8?q?refactor=20:=20ContestTemplate=EC=9D=B4?= =?UTF-8?q?=20ContestTemplateRequest=EB=A5=BC=20=EC=9D=B8=EC=9E=90?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ContestCommandService.java | 104 +++++++----------- .../contest/domain/ContestTemplate.java | 81 +++++--------- 2 files changed, 69 insertions(+), 116 deletions(-) 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 26f9d5f7..a858128c 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 @@ -44,7 +44,6 @@ 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 java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -254,22 +253,11 @@ private void applyCustomSortToTeams(final List request } public void createTemplate(final Contest contest, final String categoryName) { - final Map settings = getDefaultTemplate(categoryName); + final ContestTemplateRequest request = getDefaultTemplateRequest(categoryName); final ContestTemplate template = ContestTemplate.builder() .contest(contest) - .trackRequired(settings.get("track")) - .projectNameRequired(settings.get("projectName")) - .teamNameRequired(settings.get("teamName")) - .leaderRequired(settings.get("leader")) - .teamMembersRequired(settings.get("teamMembers")) - .professorRequired(settings.get("professor")) - .githubPathRequired(settings.get("githubPath")) - .youTubePathRequired(settings.get("youTubePath")) - .productionPathRequired(settings.get("productionPath")) - .overviewRequired(settings.get("overview")) - .posterRequired(settings.get("poster")) - .imagesRequired(settings.get("images")) + .request(request) .build(); contestTemplateRepository.save(template); @@ -278,60 +266,46 @@ public void createTemplate(final Contest contest, final String categoryName) { public void updateContestTemplate(final Long contestId, final ContestTemplateRequest request) { contestConvenience.validateExistContest(contestId); final ContestTemplate template = contestTemplateConvenience.getValidateExistTemplate(contestId); - - template.updateTemplate( - request.trackRequired(), request.projectNameRequired(), request.teamNameRequired(), - request.leaderRequired(), request.teamMembersRequired(), request.professorRequired(), - request.githubPathRequired(), request.youTubePathRequired(), request.productionPathRequired(), - request.overviewRequired(), request.posterRequired(), request.imagesRequired() - ); + template.updateTemplate(request); } - private Map getDefaultTemplate(final String categoryName) { - Map map = new HashMap<>(); - + private ContestTemplateRequest getDefaultTemplateRequest(final String categoryName) { if (categoryName != null && categoryName.contains("창의융합")) { - map.put("track", true); - map.put("projectName", true); - map.put("teamName", true); - map.put("leader", true); - map.put("teamMembers", true); - map.put("professor", false); - map.put("githubPath", true); - map.put("youTubePath", false); - map.put("productionPath", false); - map.put("overview", true); - map.put("poster", true); - map.put("images", true); - - } else if (categoryName != null && categoryName.contains("캡스톤")) { - map.put("track", true); - map.put("projectName", true); - map.put("teamName", true); - map.put("leader", true); - map.put("teamMembers", true); - map.put("professor", true); - map.put("githubPath", true); - map.put("youTubePath", true); - map.put("productionPath", false); - map.put("overview", true); - map.put("poster", false); - map.put("images", true); - - } else { - map.put("track", false); - map.put("projectName", false); - map.put("teamName", false); - map.put("leader", false); - map.put("teamMembers", false); - map.put("professor", false); - map.put("githubPath", false); - map.put("youTubePath", false); - map.put("productionPath", false); - map.put("overview", false); - map.put("poster", false); - map.put("images", false); + return new ContestTemplateRequest( + true, + true, + true, + true, + true, + false, + true, + false, + false, + true, + true, + true + ); } - return map; + + if (categoryName != null && categoryName.contains("캡스톤")) { + return new ContestTemplateRequest( + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + false, + true + ); + } + + return new ContestTemplateRequest( + false, false, false, false, false, false, false, false, false, false, false, false + ); } } diff --git a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java index deb52c7b..1d9e5d18 100644 --- a/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java +++ b/src/main/java/com/opus/opus/modules/contest/domain/ContestTemplate.java @@ -3,6 +3,7 @@ import static jakarta.persistence.FetchType.LAZY; import com.opus.opus.global.base.BaseEntity; +import com.opus.opus.modules.contest.application.dto.request.ContestTemplateRequest; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; @@ -72,60 +73,38 @@ public class ContestTemplate extends BaseEntity { private Boolean isDeleted; @Builder - private ContestTemplate( - final Contest contest, - final Boolean trackRequired, - final Boolean projectNameRequired, - final Boolean teamNameRequired, - final Boolean leaderRequired, - final Boolean teamMembersRequired, - final Boolean professorRequired, - final Boolean githubPathRequired, - final Boolean youTubePathRequired, - final Boolean productionPathRequired, - final Boolean overviewRequired, - final Boolean posterRequired, - final Boolean imagesRequired) { + private ContestTemplate(final Contest contest, final ContestTemplateRequest request + ) { this.contest = contest; - this.trackRequired = trackRequired; - this.projectNameRequired = projectNameRequired; - this.teamNameRequired = teamNameRequired; - this.leaderRequired = leaderRequired; - this.teamMembersRequired = teamMembersRequired; - this.professorRequired = professorRequired; - this.githubPathRequired = githubPathRequired; - this.youTubePathRequired = youTubePathRequired; - this.productionPathRequired = productionPathRequired; - this.overviewRequired = overviewRequired; - this.posterRequired = posterRequired; - this.imagesRequired = imagesRequired; + + this.trackRequired = request.trackRequired(); + this.projectNameRequired = request.projectNameRequired(); + this.teamNameRequired = request.teamNameRequired(); + this.leaderRequired = request.leaderRequired(); + this.teamMembersRequired = request.teamMembersRequired(); + this.professorRequired = request.professorRequired(); + this.githubPathRequired = request.githubPathRequired(); + this.youTubePathRequired = request.youTubePathRequired(); + this.productionPathRequired = request.productionPathRequired(); + this.overviewRequired = request.overviewRequired(); + this.posterRequired = request.posterRequired(); + this.imagesRequired = request.imagesRequired(); + this.isDeleted = false; } - public void updateTemplate( - final Boolean trackRequired, - final Boolean projectNameRequired, - final Boolean teamNameRequired, - final Boolean leaderRequired, - final Boolean teamMembersRequired, - final Boolean professorRequired, - final Boolean githubPathRequired, - final Boolean youTubePathRequired, - final Boolean productionPathRequired, - final Boolean overviewRequired, - final Boolean posterRequired, - final Boolean imagesRequired) { - this.trackRequired = trackRequired; - this.projectNameRequired = projectNameRequired; - this.teamNameRequired = teamNameRequired; - this.leaderRequired = leaderRequired; - this.teamMembersRequired = teamMembersRequired; - this.professorRequired = professorRequired; - this.githubPathRequired = githubPathRequired; - this.youTubePathRequired = youTubePathRequired; - this.productionPathRequired = productionPathRequired; - this.overviewRequired = overviewRequired; - this.posterRequired = posterRequired; - this.imagesRequired = imagesRequired; + public void updateTemplate(final ContestTemplateRequest request) { + this.trackRequired = request.trackRequired(); + this.projectNameRequired = request.projectNameRequired(); + this.teamNameRequired = request.teamNameRequired(); + this.leaderRequired = request.leaderRequired(); + this.teamMembersRequired = request.teamMembersRequired(); + this.professorRequired = request.professorRequired(); + this.githubPathRequired = request.githubPathRequired(); + this.youTubePathRequired = request.youTubePathRequired(); + this.productionPathRequired = request.productionPathRequired(); + this.overviewRequired = request.overviewRequired(); + this.posterRequired = request.posterRequired(); + this.imagesRequired = request.imagesRequired(); } }