diff --git a/backend/.gitignore b/backend/.gitignore index ebea50a63..639bf54f5 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -38,4 +38,4 @@ out/ application.properties -moadong.json \ No newline at end of file +moadong.json diff --git a/backend/build.gradle b/backend/build.gradle index f67b53e51..cb2df846b 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -40,11 +40,53 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // google drive + implementation 'com.google.api-client:google-api-client:2.0.0' + implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1' + implementation 'com.google.apis:google-api-services-drive:v3-rev20220815-2.0.0' + + // S3 + implementation platform('software.amazon.awssdk:bom:2.25.8') + implementation 'software.amazon.awssdk:s3' + implementation 'software.amazon.awssdk:auth' + + // resize tool + implementation 'net.coobird:thumbnailator:0.4.14' + implementation 'org.springframework:spring-test' + } -tasks.named('test') { +//전체 테스트 +test { + description = 'Runs the total tests.' useJUnitPlatform() } + +//유닛 테스트 +task unitTest(type: Test) { + group = 'verification' + description = 'Runs the unit tests.' + useJUnitPlatform{ + includeTags 'UnitTest' + excludeTags 'IntegrationTest' + + } +} + +//통합 테스트 +task integrationTest(type: Test) { + group = 'verification' + description = 'Runs the integration tests.' + useJUnitPlatform{ + includeTags 'IntegrationTest' + excludeTags 'UnitTest' + } +} diff --git a/backend/src/main/java/moadong/MoadongApplication.java b/backend/src/main/java/moadong/MoadongApplication.java index 2174768f0..21e4067a2 100644 --- a/backend/src/main/java/moadong/MoadongApplication.java +++ b/backend/src/main/java/moadong/MoadongApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) @RequiredArgsConstructor +@EnableScheduling public class MoadongApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/moadong/club/controller/ClubApplyController.java b/backend/src/main/java/moadong/club/controller/ClubApplyController.java new file mode 100644 index 000000000..27760c6df --- /dev/null +++ b/backend/src/main/java/moadong/club/controller/ClubApplyController.java @@ -0,0 +1,63 @@ +package moadong.club.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import moadong.club.payload.request.ClubApplicationCreateRequest; +import moadong.club.payload.request.ClubApplicationEditRequest; +import moadong.club.payload.request.ClubApplyRequest; +import moadong.club.service.ClubApplyService; +import moadong.global.payload.Response; +import moadong.user.annotation.CurrentUser; +import moadong.user.payload.CustomUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/club/{clubId}") +@AllArgsConstructor +@Tag(name = "Club_Apply", description = "클럽 지원서 API") +public class ClubApplyController { + + private final ClubApplyService clubApplyService; + + @PostMapping("/application") + @Operation(summary = "클럽 지원서 생성", description = "클럽 지원서를 생성합니다") + @PreAuthorize("isAuthenticated()") + @SecurityRequirement(name = "BearerAuth") + public ResponseEntity createClubApplication(@PathVariable String clubId, + @CurrentUser CustomUserDetails user, + @RequestBody @Validated ClubApplicationCreateRequest request) { + clubApplyService.createClubApplication(clubId, user, request); + return Response.ok("success create application"); + } + + @PutMapping("/application") + @Operation(summary = "클럽 지원서 수정", description = "클럽 지원서를 수정합니다") + @PreAuthorize("isAuthenticated()") + @SecurityRequirement(name = "BearerAuth") + public ResponseEntity editClubApplication(@PathVariable String clubId, + @CurrentUser CustomUserDetails user, + @RequestBody @Validated ClubApplicationEditRequest request) { + clubApplyService.editClubApplication(clubId, user, request); + return Response.ok("success edit application"); + } + + @GetMapping("/apply") + @Operation(summary = "클럽 지원서 불러오기", description = "클럽 지원서를 불러옵니다") + public ResponseEntity getClubApplication(@PathVariable String clubId) { + return clubApplyService.getClubApplication(clubId); + } + + @PostMapping("/apply") + @Operation(summary = "클럽 지원", description = "클럽에 지원합니다") + public ResponseEntity applyToClub(@PathVariable String clubId, + @RequestBody @Validated ClubApplyRequest request) { + clubApplyService.applyToClub(clubId, request); + return Response.ok("success apply"); + } + +} diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 53d39f179..879637d70 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -6,12 +6,14 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import java.util.List; +import java.util.Map; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; -import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Field; @@ -41,9 +43,11 @@ public class Club { private String userId; + private Map socialLinks; @Field("recruitmentInformation") private ClubRecruitmentInformation clubRecruitmentInformation; + public Club() { this.name = ""; this.category = ""; @@ -51,6 +55,7 @@ public Club() { this.state = ClubState.UNAVAILABLE; this.clubRecruitmentInformation = ClubRecruitmentInformation.builder().build(); } + public Club(String userId) { this.name = ""; this.category = ""; @@ -74,6 +79,7 @@ public void update(ClubInfoRequest request) { this.category = request.category(); this.division = request.division(); this.state = ClubState.AVAILABLE; + this.socialLinks = request.socialLinks(); this.clubRecruitmentInformation.update(request); } @@ -88,4 +94,8 @@ public void updateLogo(String logo) { public void updateFeedImages(List feedImages) { this.clubRecruitmentInformation.updateFeedImages(feedImages); } + + public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus) { + this.clubRecruitmentInformation.updateRecruitmentStatus(clubRecruitmentStatus); + } } diff --git a/backend/src/main/java/moadong/club/entity/ClubApplication.java b/backend/src/main/java/moadong/club/entity/ClubApplication.java new file mode 100644 index 000000000..0b17f2153 --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubApplication.java @@ -0,0 +1,38 @@ +package moadong.club.entity; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import moadong.club.enums.ApplicationStatus; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Document("club_applications") +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubApplication { + + @Id + private String id; + + private String questionId; + + @Enumerated(EnumType.STRING) + @Builder.Default + ApplicationStatus status = ApplicationStatus.SUBMITTED; + + @Builder.Default + private List answers = new ArrayList<>(); + + @Builder.Default + LocalDateTime createdAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); +} diff --git a/backend/src/main/java/moadong/club/entity/ClubApplicationQuestion.java b/backend/src/main/java/moadong/club/entity/ClubApplicationQuestion.java new file mode 100644 index 000000000..b0427494a --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubApplicationQuestion.java @@ -0,0 +1,37 @@ +package moadong.club.entity; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import moadong.club.enums.ClubApplicationQuestionType; + +import java.util.List; + +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubApplicationQuestion { + + @NotNull + private Long id; + + @NotNull + private String title; + + @NotNull + private String description; + + @Enumerated(EnumType.STRING) + @NotNull + private ClubApplicationQuestionType type; + + @NotNull + private ClubQuestionOption options; + + @NotNull + private List items; + +} diff --git a/backend/src/main/java/moadong/club/entity/ClubMetric.java b/backend/src/main/java/moadong/club/entity/ClubMetric.java index ab14caa34..c5a91300b 100644 --- a/backend/src/main/java/moadong/club/entity/ClubMetric.java +++ b/backend/src/main/java/moadong/club/entity/ClubMetric.java @@ -3,6 +3,8 @@ import jakarta.persistence.Id; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -30,7 +32,7 @@ public class ClubMetric { @Builder public ClubMetric(String clubId, String ip) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); this.clubId = clubId; this.ip = ip; this.inAt = now; @@ -39,6 +41,6 @@ public ClubMetric(String clubId, String ip) { } public void update() { - this.outAt = LocalDateTime.now(); + this.outAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); } } diff --git a/backend/src/main/java/moadong/club/entity/ClubQuestion.java b/backend/src/main/java/moadong/club/entity/ClubQuestion.java new file mode 100644 index 000000000..92fc16afe --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubQuestion.java @@ -0,0 +1,53 @@ +package moadong.club.entity; + +import jakarta.persistence.Id; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; + +@Document("club_questions") +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubQuestion { + + @Id + private String id; + + private String clubId; + + @NotBlank + @Builder.Default + private String title = ""; + + @Builder.Default + private List questions = new ArrayList<>(); + + @Builder.Default + private LocalDateTime createdAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + + @Builder.Default + private LocalDateTime editedAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + + public void updateFormTitle(String title) { + this.title = title; + } + + public void updateQuestions(List newQuestions) { + this.questions.clear(); + this.questions.addAll(newQuestions); + } + + public void updateEditedAt() { + this.editedAt = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime(); + } + +} diff --git a/backend/src/main/java/moadong/club/entity/ClubQuestionAnswer.java b/backend/src/main/java/moadong/club/entity/ClubQuestionAnswer.java new file mode 100644 index 000000000..24f304f5b --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubQuestionAnswer.java @@ -0,0 +1,16 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubQuestionAnswer { + + private Long id; + + private String value; + +} diff --git a/backend/src/main/java/moadong/club/entity/ClubQuestionItem.java b/backend/src/main/java/moadong/club/entity/ClubQuestionItem.java new file mode 100644 index 000000000..1941223e8 --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubQuestionItem.java @@ -0,0 +1,14 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubQuestionItem { + + private String value; + +} diff --git a/backend/src/main/java/moadong/club/entity/ClubQuestionOption.java b/backend/src/main/java/moadong/club/entity/ClubQuestionOption.java new file mode 100644 index 000000000..ef62058b3 --- /dev/null +++ b/backend/src/main/java/moadong/club/entity/ClubQuestionOption.java @@ -0,0 +1,14 @@ +package moadong.club.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@AllArgsConstructor +@Getter +@Builder(toBuilder = true) +public class ClubQuestionOption { + + private Boolean required; + +} diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index a5f6f0433..6c94b727f 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -10,13 +10,12 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.List; - import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import moadong.club.enums.ClubRecruitmentStatus; -import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.global.RegexConstants; import org.checkerframework.common.aliasing.qual.Unique; @@ -52,14 +51,13 @@ public class ClubRecruitmentInformation { private String recruitmentTarget; private List feedImages; + private List tags; @Enumerated(EnumType.STRING) @NotNull private ClubRecruitmentStatus clubRecruitmentStatus; - private String recruitmentForm; - public ClubRecruitmentInformation updateLogo(String logo) { this.logo = logo; return this; @@ -82,19 +80,25 @@ public boolean hasRecruitmentPeriod() { public ZonedDateTime getRecruitmentStart() { ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + if (recruitmentStart == null) { + return null; + } return recruitmentStart.atZone(seoulZone); } public ZonedDateTime getRecruitmentEnd() { ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + if (recruitmentEnd == null) { + return null; + } return recruitmentEnd.atZone(seoulZone); } - public int getFeedAmounts(){ + public int getFeedAmounts() { return this.feedImages.size(); } - public void updateFeedImages(List feedImages){ + public void updateFeedImages(List feedImages) { this.feedImages = feedImages; } @@ -103,6 +107,5 @@ public void update(ClubInfoRequest request) { this.presidentName = request.presidentName(); this.presidentTelephoneNumber = request.presidentPhoneNumber(); this.tags = request.tags(); - this.recruitmentForm = request.recruitmentForm(); } } diff --git a/backend/src/main/java/moadong/club/enums/ApplicationStatus.java b/backend/src/main/java/moadong/club/enums/ApplicationStatus.java new file mode 100644 index 000000000..f1f712a6f --- /dev/null +++ b/backend/src/main/java/moadong/club/enums/ApplicationStatus.java @@ -0,0 +1,17 @@ +package moadong.club.enums; + +public enum ApplicationStatus { + DRAFT, // 작성 중 + SUBMITTED, // 제출 완료 + SCREENING, // 서류 심사 중 + SCREENING_PASSED, // 서류 통과 + SCREENING_FAILED, // 서류 탈락 + INTERVIEW_SCHEDULED, // 면접 일정 확정 + INTERVIEW_IN_PROGRESS, // 면접 진행 중 + INTERVIEW_PASSED, // 면접 통과 + INTERVIEW_FAILED, // 면접 탈락 + OFFERED, // 최종 합격 제안 + ACCEPTED, // 제안 수락 + DECLINED, // 제안 거절 + CANCELED_BY_APPLICANT // 지원자 자진 철회 +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/club/enums/ClubApplicationQuestionType.java b/backend/src/main/java/moadong/club/enums/ClubApplicationQuestionType.java new file mode 100644 index 000000000..964e84ae0 --- /dev/null +++ b/backend/src/main/java/moadong/club/enums/ClubApplicationQuestionType.java @@ -0,0 +1,14 @@ +package moadong.club.enums; + +import lombok.Getter; + +@Getter +public enum ClubApplicationQuestionType { + CHOICE, + MULTI_CHOICE, + SHORT_TEXT, + LONG_TEXT, + PHONE_NUMBER, + EMAIL, + NAME; +} diff --git a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java index 6f506e406..c39aac687 100644 --- a/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java +++ b/backend/src/main/java/moadong/club/payload/dto/ClubDetailedResult.java @@ -2,6 +2,7 @@ import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Map; import lombok.Builder; import moadong.club.entity.Club; import moadong.club.entity.ClubRecruitmentInformation; @@ -21,7 +22,7 @@ public record ClubDetailedResult( String recruitmentPeriod, String recruitmentTarget, String recruitmentStatus, - String recruitmentForm, + Map socialLinks, String category, String division ) { @@ -35,24 +36,34 @@ public static ClubDetailedResult of(Club club) { + clubRecruitmentInformation.getRecruitmentEnd().format(formatter); } return ClubDetailedResult.builder() - .id(club.getId() == null ? "" : club.getId()) - .name(club.getName() == null ? "" : club.getName()) - .logo(clubRecruitmentInformation.getLogo() == null ? "" : clubRecruitmentInformation.getLogo()) - .tags(clubRecruitmentInformation.getTags() == null ? List.of() : clubRecruitmentInformation.getTags()) - .state(club.getState() == null ? "" : club.getState().getDesc()) - .feeds(clubRecruitmentInformation.getFeedImages() == null ? List.of() : clubRecruitmentInformation.getFeedImages()) - .category(club.getCategory() == null ? "" : club.getCategory()) - .division(club.getDivision() == null ? "" : club.getDivision()) - .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" : clubRecruitmentInformation.getIntroduction()) - .description(clubRecruitmentInformation.getDescription() == null ? "" : clubRecruitmentInformation.getDescription()) - .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" : clubRecruitmentInformation.getPresidentName()) - .presidentPhoneNumber(clubRecruitmentInformation.getPresidentTelephoneNumber() == null ? "" : clubRecruitmentInformation.getPresidentTelephoneNumber()) - .recruitmentPeriod(period) - .recruitmentTarget(clubRecruitmentInformation.getRecruitmentTarget() == null ? "" : clubRecruitmentInformation.getRecruitmentTarget()) - .recruitmentStatus(clubRecruitmentInformation.getClubRecruitmentStatus() == null - ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().getDescription()) - .recruitmentForm(clubRecruitmentInformation.getRecruitmentForm() == null ? "" : clubRecruitmentInformation.getRecruitmentForm()) - .build(); + .id(club.getId() == null ? "" : club.getId()) + .name(club.getName() == null ? "" : club.getName()) + .logo(clubRecruitmentInformation.getLogo() == null ? "" + : clubRecruitmentInformation.getLogo()) + .tags(clubRecruitmentInformation.getTags() == null ? List.of() + : clubRecruitmentInformation.getTags()) + .state(club.getState() == null ? "" : club.getState().getDesc()) + .feeds(clubRecruitmentInformation.getFeedImages() == null ? List.of() + : clubRecruitmentInformation.getFeedImages()) + .category(club.getCategory() == null ? "" : club.getCategory()) + .division(club.getDivision() == null ? "" : club.getDivision()) + .introduction(clubRecruitmentInformation.getIntroduction() == null ? "" + : clubRecruitmentInformation.getIntroduction()) + .description(clubRecruitmentInformation.getDescription() == null ? "" + : clubRecruitmentInformation.getDescription()) + .presidentName(clubRecruitmentInformation.getPresidentName() == null ? "" + : clubRecruitmentInformation.getPresidentName()) + .presidentPhoneNumber( + clubRecruitmentInformation.getPresidentTelephoneNumber() == null ? "" + : clubRecruitmentInformation.getPresidentTelephoneNumber()) + .recruitmentPeriod(period) + .recruitmentTarget(clubRecruitmentInformation.getRecruitmentTarget() == null ? "" + : clubRecruitmentInformation.getRecruitmentTarget()) + .recruitmentStatus(clubRecruitmentInformation.getClubRecruitmentStatus() == null + ? "" : clubRecruitmentInformation.getClubRecruitmentStatus().getDescription()) + .socialLinks(club.getSocialLinks() == null ? Map.of() + : club.getSocialLinks()) + .build(); } } diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationCreateRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationCreateRequest.java new file mode 100644 index 000000000..9148cf0ba --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationCreateRequest.java @@ -0,0 +1,19 @@ +package moadong.club.payload.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record ClubApplicationCreateRequest( + @NotBlank + @Size(max = 20) + String title, + + @NotNull + @Valid + List questions +) { +} diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java new file mode 100644 index 000000000..a9167e037 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplicationEditRequest.java @@ -0,0 +1,19 @@ +package moadong.club.payload.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public record ClubApplicationEditRequest( + @NotBlank + @Size(max = 20) + String title, + + @NotNull + @Valid + List questions +) { +} diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplyQuestion.java b/backend/src/main/java/moadong/club/payload/request/ClubApplyQuestion.java new file mode 100644 index 000000000..c74c8d3b2 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplyQuestion.java @@ -0,0 +1,38 @@ +package moadong.club.payload.request; + + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import moadong.club.enums.ClubApplicationQuestionType; + +import java.util.List; + +public record ClubApplyQuestion( + @NotNull + Long id, + @NotBlank + @Size(max = 20) + String title, + @NotNull //빈칸 허용 + String description, + @NotNull + ClubApplicationQuestionType type, + @NotNull + Options options, + @NotNull + @Valid + List items +) { + public record Options( + @NotNull + boolean required + ) {} + + public record QuestionItem( + @NotNull + @Size(max = 20) + String value + ) {} +} \ No newline at end of file diff --git a/backend/src/main/java/moadong/club/payload/request/ClubApplyRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubApplyRequest.java new file mode 100644 index 000000000..a4d9d994b --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/request/ClubApplyRequest.java @@ -0,0 +1,20 @@ +package moadong.club.payload.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record ClubApplyRequest( + @NotNull + @Valid + List questions +) { + public record Answer( + @NotNull + Long id, + @NotNull //빈칸 상관없음 + String value + ) { + } +} diff --git a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java index 74037337f..f97bf4f10 100644 --- a/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java +++ b/backend/src/main/java/moadong/club/payload/request/ClubInfoRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotBlank; import java.util.List; +import java.util.Map; public record ClubInfoRequest( @NotBlank @@ -16,7 +17,7 @@ public record ClubInfoRequest( String introduction, String presidentName, String presidentPhoneNumber, - String recruitmentForm + Map socialLinks ) { } diff --git a/backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java b/backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java new file mode 100644 index 000000000..e22415897 --- /dev/null +++ b/backend/src/main/java/moadong/club/payload/response/ClubApplicationResponse.java @@ -0,0 +1,13 @@ +package moadong.club.payload.response; + +import lombok.Builder; +import moadong.club.entity.ClubApplicationQuestion; + +import java.util.List; + +@Builder +public record ClubApplicationResponse( + String title, + List questions +) { +} diff --git a/backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java b/backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java new file mode 100644 index 000000000..12cbdadef --- /dev/null +++ b/backend/src/main/java/moadong/club/repository/ClubApplicationRepository.java @@ -0,0 +1,7 @@ +package moadong.club.repository; + +import moadong.club.entity.ClubApplication; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface ClubApplicationRepository extends MongoRepository { +} diff --git a/backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java b/backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java new file mode 100644 index 000000000..213039403 --- /dev/null +++ b/backend/src/main/java/moadong/club/repository/ClubQuestionRepository.java @@ -0,0 +1,14 @@ +package moadong.club.repository; + +import moadong.club.entity.ClubQuestion; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ClubQuestionRepository extends MongoRepository { + + Optional findByClubId(String clubId); + +} diff --git a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java index e506210a6..7c21358ae 100644 --- a/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java +++ b/backend/src/main/java/moadong/club/repository/ClubSearchRepository.java @@ -49,7 +49,7 @@ public List searchClubsByKeyword(String keyword, String recrui operations.add( Aggregation.project("name", "state", "category", "division") .and("recruitmentInformation.introduction").as("introduction") - .and("recruitmentInformation.recruitmentStatus").as("recruitmentStatus") + .and("recruitmentInformation.clubRecruitmentStatus").as("recruitmentStatus") .and(ConditionalOperators.ifNull("$recruitmentInformation.logo").then("")) .as("logo") .and(ConditionalOperators.ifNull("$recruitmentInformation.tags").then("")) diff --git a/backend/src/main/java/moadong/club/service/ClubApplyService.java b/backend/src/main/java/moadong/club/service/ClubApplyService.java new file mode 100644 index 000000000..8729cb6b1 --- /dev/null +++ b/backend/src/main/java/moadong/club/service/ClubApplyService.java @@ -0,0 +1,206 @@ +package moadong.club.service; + +import lombok.AllArgsConstructor; +import moadong.club.entity.*; +import moadong.club.enums.ClubApplicationQuestionType; +import moadong.club.payload.request.ClubApplicationCreateRequest; +import moadong.club.payload.request.ClubApplicationEditRequest; +import moadong.club.payload.request.ClubApplyRequest; +import moadong.club.payload.response.ClubApplicationResponse; +import moadong.club.repository.ClubApplicationRepository; +import moadong.club.repository.ClubQuestionRepository; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.global.payload.Response; +import moadong.user.payload.CustomUserDetails; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@AllArgsConstructor +public class ClubApplyService { + + private final ClubRepository clubRepository; + private final ClubQuestionRepository clubQuestionRepository; + private final ClubApplicationRepository clubApplicationRepository; + + public void createClubApplication(String clubId, CustomUserDetails user, ClubApplicationCreateRequest request) { + ClubQuestion clubQuestion = getClubQuestion(clubId, user); + + clubQuestionRepository.save(createQuestions(clubQuestion, request)); + } + + public void editClubApplication(String clubId, CustomUserDetails user, ClubApplicationEditRequest request) { + ClubQuestion clubQuestion = getClubQuestion(clubId, user); + + clubQuestion.updateEditedAt(); + clubQuestionRepository.save(updateQuestions(clubQuestion, request)); + } + + public ResponseEntity getClubApplication(String clubId) { + ClubQuestion clubQuestion = clubQuestionRepository.findByClubId(clubId) + .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + + ClubApplicationResponse clubApplicationResponse = ClubApplicationResponse.builder() + .title(clubQuestion.getTitle()) + .questions(clubQuestion.getQuestions()) + .build(); + + return Response.ok(clubApplicationResponse); + } + + public void applyToClub(String clubId, ClubApplyRequest request) { + ClubQuestion clubQuestion = clubQuestionRepository.findByClubId(clubId) + .orElseThrow(() -> new RestApiException(ErrorCode.APPLICATION_NOT_FOUND)); + + validateAnswers(request.questions(), clubQuestion); + + List answers = request.questions() + .stream().map(answer -> ClubQuestionAnswer.builder() + .id(answer.id()) + .value(answer.value()) + .build() + ).toList(); + + ClubApplication application = ClubApplication.builder() + .questionId(clubQuestion.getClubId()) + .answers(answers) + .build(); + + clubApplicationRepository.save(application); + } + + private void validateAnswers(List answers, ClubQuestion clubQuestion) { + // 미리 질문과 응답 id 만들어두기 + Map questionMap = clubQuestion.getQuestions().stream() + .collect(Collectors.toMap(ClubApplicationQuestion::getId, Function.identity())); + + Set answerIds = answers.stream() + .map(ClubApplyRequest.Answer::id) + .collect(Collectors.toSet()); + + // 필수 질문이 누락되었는지 검증 + for (ClubApplicationQuestion question : clubQuestion.getQuestions()) { + if (question.getOptions().getRequired() && !answerIds.contains(question.getId())) { + throw new RestApiException(ErrorCode.REQUIRED_QUESTION_MISSING); + } + } + + // 답변 유효성 검증 + for (ClubApplyRequest.Answer answer : answers) { + ClubApplicationQuestion question = questionMap.get(answer.id()); + + // 질문이 없을 경우 예외 처리 + if (question == null) { + throw new RestApiException(ErrorCode.QUESTION_NOT_FOUND); + } + + validateAnswerLength(answer.value(), question.getType()); + } + } + + + private void validateAnswerLength(String value, ClubApplicationQuestionType type) { + switch (type) { + case SHORT_TEXT -> { + if (value.length() > 30) { + throw new RestApiException(ErrorCode.SHORT_EXCEED_LENGTH); + } + } + case LONG_TEXT -> { + if (value.length() > 500) { + throw new RestApiException(ErrorCode.LONG_EXCEED_LENGTH); + } + } + } + } + + private ClubQuestion createQuestions(ClubQuestion clubQuestion, ClubApplicationCreateRequest request) { + List newQuestions = new ArrayList<>(); + + for (var question : request.questions()) { + List items = new ArrayList<>(); + + for (var item : question.items()) { + items.add(ClubQuestionItem.builder() + .value(item.value()) + .build()); + } + + ClubQuestionOption options = ClubQuestionOption.builder() + .required(question.options().required()) + .build(); + + ClubApplicationQuestion clubApplicationQuestion = ClubApplicationQuestion.builder() + .id(question.id()) + .title(question.title()) + .description(question.description()) + .type(question.type()) + .options(options) + .items(items) + .build(); + + newQuestions.add(clubApplicationQuestion); + } + + clubQuestion.updateQuestions(newQuestions); + clubQuestion.updateFormTitle(request.title()); + + return clubQuestion; + } + + private ClubQuestion updateQuestions(ClubQuestion clubQuestion, ClubApplicationEditRequest request) { + List newQuestions = new ArrayList<>(); + + for (var question : request.questions()) { + List items = new ArrayList<>(); + + for (var item : question.items()) { + items.add(ClubQuestionItem.builder() + .value(item.value()) + .build()); + } + + ClubQuestionOption options = ClubQuestionOption.builder() + .required(question.options().required()) + .build(); + + ClubApplicationQuestion clubApplicationQuestion = ClubApplicationQuestion.builder() + .id(question.id()) + .title(question.title()) + .description(question.description()) + .type(question.type()) + .options(options) + .items(items) + .build(); + + newQuestions.add(clubApplicationQuestion); + } + + clubQuestion.updateQuestions(newQuestions); + clubQuestion.updateFormTitle(request.title()); + + return clubQuestion; + } + + private ClubQuestion getClubQuestion(String clubId, CustomUserDetails user) { + Club club = clubRepository.findById(clubId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + if (!user.getId().equals(club.getUserId())) { + throw new RestApiException(ErrorCode.USER_UNAUTHORIZED); + } + + return clubQuestionRepository.findByClubId(club.getId()) + .orElseGet(() -> ClubQuestion.builder() + .clubId(club.getId()) + .build()); + } +} diff --git a/backend/src/main/java/moadong/club/service/ClubMetricService.java b/backend/src/main/java/moadong/club/service/ClubMetricService.java index 0da810b82..52f8314cb 100644 --- a/backend/src/main/java/moadong/club/service/ClubMetricService.java +++ b/backend/src/main/java/moadong/club/service/ClubMetricService.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import java.time.Period; import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; import java.util.ArrayList; @@ -28,7 +30,7 @@ public class ClubMetricService { private final ClubMetricRepository clubMetricRepository; public void patch(String clubId, String remoteAddr) { - LocalDate nowDate = LocalDate.now(); + LocalDate nowDate = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); Optional optional = clubMetricRepository.findByClubIdAndIpAndDate( clubId, remoteAddr, nowDate); @@ -47,7 +49,7 @@ public void patch(String clubId, String remoteAddr) { } public int[] getDailyActiveUserWitClub(String clubId) { - LocalDate now = LocalDate.now(); + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); LocalDate from = now.minusDays(30); List metrics = clubMetricRepository.findByClubIdAndDateAfter(clubId, from); @@ -63,7 +65,7 @@ public int[] getDailyActiveUserWitClub(String clubId) { } public int[] getWeeklyActiveUserWitClub(String clubId) { - LocalDate now = LocalDate.now(); + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); LocalDate from = now.minusDays(84); List metrics = clubMetricRepository.findByClubIdAndDateAfter(clubId, from); @@ -84,7 +86,7 @@ public int[] getWeeklyActiveUserWitClub(String clubId) { } public int[] getMonthlyActiveUserWitClub(String clubId) { - LocalDate now = LocalDate.now(); + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); YearMonth currentMonth = YearMonth.from(now); // 현재 년-월 YearMonth fromMonth = currentMonth.minusMonths(12); // 12개월 전 @@ -107,7 +109,12 @@ public int[] getMonthlyActiveUserWitClub(String clubId) { } public List getDailyRanking(int n) { - List todayMetrics = clubMetricRepository.findAllByDate(LocalDate.now()); + if (n <= 0) { + return Collections.emptyList(); + } + + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + List todayMetrics = clubMetricRepository.findAllByDate(now); Map clubViewCount = todayMetrics.stream() .collect(Collectors.groupingBy(ClubMetric::getClubId, Collectors.counting())); List> sortedList = new ArrayList<>(clubViewCount.entrySet()); @@ -130,7 +137,11 @@ public List getDailyRanking(int n) { } public int[] getDailyActiveUser(int n) { - LocalDate today = LocalDate.now(); + if (n <= 0) { + return new int[0]; + } + + LocalDate today = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); LocalDate fromDate = today.minusDays(n); List metrics = clubMetricRepository.findAllByDateAfter(fromDate); diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 6640a1fa2..c3b5e88d4 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -4,8 +4,8 @@ import moadong.club.entity.Club; import moadong.club.payload.dto.ClubDetailedResult; import moadong.club.payload.request.ClubCreateRequest; -import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.response.ClubDetailedResponse; import moadong.club.repository.ClubRepository; import moadong.global.exception.ErrorCode; @@ -20,14 +20,13 @@ public class ClubProfileService { private final ClubRepository clubRepository; - private final RecruitmentScheduler recruitmentScheduler; public String createClub(ClubCreateRequest request) { Club club = Club.builder() - .name(request.name()) - .category(request.category()) - .division(request.division()) - .build(); + .name(request.name()) + .category(request.category()) + .division(request.division()) + .build(); clubRepository.save(club); return club.getId(); @@ -40,34 +39,28 @@ public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { clubRepository.save(club); } - public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, CustomUserDetails user) { + public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, + CustomUserDetails user) { Club club = validateClubUpdateRequest(request.id(), user); - club.update(request); clubRepository.save(club); - - //모집일정을 동적스케쥴러에 달아둠 - if (request.recruitmentStart() != null && request.recruitmentEnd() != null) { - recruitmentScheduler.scheduleRecruitment(club.getId(), request.recruitmentStart(), - request.recruitmentEnd()); - } } public ClubDetailedResponse getClubDetail(String clubId) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); ClubDetailedResult clubDetailedResult = ClubDetailedResult.of( - club + club ); return new ClubDetailedResponse(clubDetailedResult); } - private Club validateClubUpdateRequest(String clubId, CustomUserDetails user){ + private Club validateClubUpdateRequest(String clubId, CustomUserDetails user) { Club club = clubRepository.findById(clubId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); - if (!user.getId().equals(club.getUserId())){ + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + if (!user.getId().equals(club.getUserId())) { throw new RestApiException(ErrorCode.USER_UNAUTHORIZED); } return club; diff --git a/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java b/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java deleted file mode 100644 index cd1e25d82..000000000 --- a/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java +++ /dev/null @@ -1,65 +0,0 @@ -package moadong.club.service; - -import jakarta.transaction.Transactional; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ScheduledFuture; -import lombok.RequiredArgsConstructor; -import moadong.club.entity.Club; -import moadong.club.enums.ClubRecruitmentStatus; -import moadong.club.repository.ClubRepository; -import moadong.global.exception.ErrorCode; -import moadong.global.exception.RestApiException; -import moadong.global.util.ObjectIdConverter; -import org.bson.types.ObjectId; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RecruitmentScheduler { - - private final TaskScheduler taskScheduler; - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - private final ClubRepository clubRepository; - - public void scheduleRecruitment(String clubId, LocalDateTime startDate, - LocalDateTime endDate) { - cancelScheduledTask(clubId); // 기존 스케줄 제거 후 등록 - - // 모집 시작 스케줄링 - ScheduledFuture startFuture = taskScheduler.schedule( - () -> updateRecruitmentStatus(clubId, ClubRecruitmentStatus.OPEN), - Date.from(startDate.atZone(ZoneId.systemDefault()).toInstant())); - - // 모집 종료 스케줄링 - ScheduledFuture endFuture = taskScheduler.schedule( - () -> updateRecruitmentStatus(clubId, ClubRecruitmentStatus.CLOSED), - Date.from(endDate.atZone(ZoneId.systemDefault()).toInstant())); - - scheduledTasks.put(clubId, startFuture); - scheduledTasks.put(clubId, endFuture); - } - - public void cancelScheduledTask(String clubId) { - ScheduledFuture future = scheduledTasks.remove(clubId); - if (future != null) { - future.cancel(false); - } - } - - @Transactional - public void updateRecruitmentStatus(String clubId, ClubRecruitmentStatus status) { - ObjectId objectId = ObjectIdConverter.convertString(clubId); - Club club = clubRepository.findClubById(objectId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); - - club.getClubRecruitmentInformation().updateRecruitmentStatus(status); - clubRepository.save(club); - } - -} diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java new file mode 100644 index 000000000..796cb6100 --- /dev/null +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -0,0 +1,51 @@ +package moadong.club.service; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.enums.ClubRecruitmentStatus; +import moadong.club.repository.ClubRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecruitmentStateChecker { + + private final ClubRepository clubRepository; + + @Scheduled(fixedRate = 60 * 60 * 1000) // 5분마다 실행 + public void performTask() { + List clubs = clubRepository.findAll(); + for (Club club : clubs) { + ClubRecruitmentInformation recruitInfo = club.getClubRecruitmentInformation(); + ZonedDateTime recruitmentStartDate = recruitInfo.getRecruitmentStart(); + ZonedDateTime recruitmentEndDate = recruitInfo.getRecruitmentEnd(); + if (recruitInfo.getClubRecruitmentStatus() == ClubRecruitmentStatus.ALWAYS) { + continue; + } + if (recruitmentStartDate != null && recruitmentEndDate != null) { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + if (now.isBefore(recruitmentStartDate)) { + long between = ChronoUnit.DAYS.between(recruitmentStartDate, now); + if (between <= 14) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); + } else { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + } else if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); + } else if (now.isAfter(recruitmentEndDate)) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + } else { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + clubRepository.save(club); + } + } +} diff --git a/backend/src/main/java/moadong/global/config/SchedulerConfig.java b/backend/src/main/java/moadong/global/config/SchedulerConfig.java deleted file mode 100644 index ffcc46808..000000000 --- a/backend/src/main/java/moadong/global/config/SchedulerConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package moadong.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; - -@Configuration -public class SchedulerConfig { - - @Bean - public TaskScheduler taskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(5); // 동시에 실행할 스레드 개수 설정 - scheduler.setThreadNamePrefix("RecruitmentScheduler-"); - scheduler.initialize(); - return scheduler; - } -} diff --git a/backend/src/main/java/moadong/global/exception/ErrorCode.java b/backend/src/main/java/moadong/global/exception/ErrorCode.java index f157b61cf..b630a8e8b 100644 --- a/backend/src/main/java/moadong/global/exception/ErrorCode.java +++ b/backend/src/main/java/moadong/global/exception/ErrorCode.java @@ -15,6 +15,8 @@ public enum ErrorCode { TOO_MANY_FILES(HttpStatus.PAYLOAD_TOO_LARGE, "601-3", "이미지 파일이 최대치보다 많습니다."), IMAGE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "601-4", "이미지 삭제에 실패하였습니다"), KOREAN_FILE_NAME(HttpStatus.INTERNAL_SERVER_ERROR, "601-5", "파일명의 한국어를 인코딩할 수 없습니다."), + FILE_TRANSFER_ERROR(HttpStatus.BAD_REQUEST, "601-6", "파일을 올바른 형식으로 변경할 수 없습니다."), + UNSUPPORTED_FILE_TYPE(HttpStatus.BAD_REQUEST, "601-7", "파일의 확장자가 올바르지 않습니다."), USER_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "700-1","이미 존재하는 계정입니다."), USER_NOT_EXIST(HttpStatus.BAD_REQUEST, "700-2","존재하지 않는 계정입니다."), USER_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "700-3","올바르지 않은 유저 형식입니다."), @@ -22,6 +24,11 @@ public enum ErrorCode { USER_UNAUTHORIZED(HttpStatus.FORBIDDEN, "700-5","권한이 없습니다."), TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "701-1", "올바르지 않은 토큰 양식입니다."), TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "701-2", "토큰이 만료되었습니다."), + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-1", "지원서가 존재하지 않습니다."), + SHORT_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-2", "단답형 최대 글자를 초과하였습니다."), + LONG_EXCEED_LENGTH(HttpStatus.BAD_REQUEST, "800-3", "장문형 최대 글자를 초과하였습니다."), + QUESTION_NOT_FOUND(HttpStatus.NOT_FOUND, "800-4", "존재하지 않은 질문입니다."), + REQUIRED_QUESTION_MISSING(HttpStatus.BAD_REQUEST, "800-5", "필수 응답 질문이 누락되었습니다."), ; private final HttpStatus httpStatus; diff --git a/backend/src/main/java/moadong/global/validator/KoreanValidator.java b/backend/src/main/java/moadong/global/validator/KoreanValidator.java index 1652e06e1..828eff41d 100644 --- a/backend/src/main/java/moadong/global/validator/KoreanValidator.java +++ b/backend/src/main/java/moadong/global/validator/KoreanValidator.java @@ -7,6 +7,8 @@ import java.util.regex.Pattern; public class KoreanValidator implements ConstraintValidator { + // 1 ~ 10자 오직 한글만 가능, 단 자음 또는 모음만 있는 경우는 제외 + // TODO: 테스트 코드 작성 private static final Pattern KOREAN_ONLY_PATTERN = Pattern.compile("^[가-힣]{1,10}$"); @Override diff --git a/backend/src/main/java/moadong/global/validator/PasswordValidator.java b/backend/src/main/java/moadong/global/validator/PasswordValidator.java index 0e67cdebd..3382efe3b 100644 --- a/backend/src/main/java/moadong/global/validator/PasswordValidator.java +++ b/backend/src/main/java/moadong/global/validator/PasswordValidator.java @@ -7,12 +7,16 @@ import java.util.regex.Pattern; public class PasswordValidator implements ConstraintValidator { - private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^]).{8,20}$"); + private static final String REGEX = "^(?=.*[a-zA-Z])(?=.*[0-9])(?=.*[!@#$%^])(?!.*\\s).{8,20}$"; + private static final Pattern PASSWORD_PATTERN = Pattern.compile(REGEX); + @Override - public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { + public boolean isValid(String s, ConstraintValidatorContext context) { if (s == null || s.isEmpty()) { - return true; + return false; } return PASSWORD_PATTERN.matcher(s).matches(); } + } + diff --git a/backend/src/main/java/moadong/global/validator/PhoneNumberValidator.java b/backend/src/main/java/moadong/global/validator/PhoneNumberValidator.java index c884066cb..282d35b06 100644 --- a/backend/src/main/java/moadong/global/validator/PhoneNumberValidator.java +++ b/backend/src/main/java/moadong/global/validator/PhoneNumberValidator.java @@ -7,6 +7,12 @@ import java.util.regex.Pattern; public class PhoneNumberValidator implements ConstraintValidator { + /* + 두 개의 검사가 결합되어 있음. + (1) 010-1234-5678 또는 01012345678 인지 검사 + (2) 02-123-4567 또는 031-1234-5678 인지 검사 + TODO: 테스트 코드 작성 + */ private static final Pattern PHONE_NUMBER_PATTERN = Pattern.compile("^(01[016789]-?\\d{3,4}-?\\d{4})|(0\\d{1,2}-?\\d{3,4}-?\\d{4})$"); @Override diff --git a/backend/src/main/java/moadong/global/validator/UserIdValidator.java b/backend/src/main/java/moadong/global/validator/UserIdValidator.java index a0a248385..3c5cb9b3c 100644 --- a/backend/src/main/java/moadong/global/validator/UserIdValidator.java +++ b/backend/src/main/java/moadong/global/validator/UserIdValidator.java @@ -7,6 +7,7 @@ import java.util.regex.Pattern; public class UserIdValidator implements ConstraintValidator { + // 5 ~ 20자 사이의 최소 한 개의 소문자 영어, 최소 한 개의 숫자가 포함되도록 검사. 이때, !@#$~만 포함 가능 private static final Pattern USER_ID_PATTERN = Pattern.compile("^(?=.*[a-z])(?=.*\\d)[a-zA-Z\\d!@#$~]{5,20}$"); @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { diff --git a/backend/src/main/java/moadong/gcs/controller/ClubImageController.java b/backend/src/main/java/moadong/media/controller/ClubImageController.java similarity index 75% rename from backend/src/main/java/moadong/gcs/controller/ClubImageController.java rename to backend/src/main/java/moadong/media/controller/ClubImageController.java index 3eab3e8be..0717c8141 100644 --- a/backend/src/main/java/moadong/gcs/controller/ClubImageController.java +++ b/backend/src/main/java/moadong/media/controller/ClubImageController.java @@ -1,13 +1,12 @@ -package moadong.gcs.controller; +package moadong.media.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; -import moadong.gcs.domain.FileType; -import moadong.gcs.dto.FeedUpdateRequest; -import moadong.gcs.service.ClubImageService; import moadong.global.payload.Response; +import moadong.media.dto.FeedUpdateRequest; +import moadong.media.service.ClubImageService; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; @@ -22,39 +21,40 @@ @RestController @RequestMapping("/api/club") -@RequiredArgsConstructor @Tag(name = "ClubImage", description = "클럽 이미지 관련 API") @PreAuthorize("isAuthenticated()") @SecurityRequirement(name = "BearerAuth") public class ClubImageController { - private final ClubImageService clubImageService; + public ClubImageController(@Qualifier("cloudflare") ClubImageService clubImageService) { + this.clubImageService = clubImageService; + } + private final ClubImageService clubImageService; @PostMapping(value = "/{clubId}/logo", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "로고 이미지 업데이트", description = "로고 이미지를 업데이트합니다.") + @Operation(summary = "로고 이미지 업데이트", description = "cloudflare 상에 로고 이미지를 업데이트합니다.") public ResponseEntity uploadLogo(@PathVariable String clubId, - @RequestPart("logo") MultipartFile file) { + @RequestPart("logo") MultipartFile file) { String fileUrl = clubImageService.uploadLogo(clubId, file); return Response.ok(fileUrl); } @DeleteMapping(value = "/{clubId}/logo") - @Operation(summary = "로고 이미지 삭제", description = "로고 이미지를 저장소에서 삭제합니다.") + @Operation(summary = "로고 이미지 삭제", description = "cloudflare 상에 로고 이미지를 저장소에서 삭제합니다.") public ResponseEntity deleteLogo(@PathVariable String clubId) { clubImageService.deleteLogo(clubId); return Response.ok("success delete logo"); } - // TODO : Signed URL 을 통한 업로드로 추후 변경 @PostMapping(value = "/{clubId}/feed", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "피드 이미지 업로드", description = "피드에 사용할 이미지를 업로드하고 주소를 반환받습니다.") + @Operation(summary = "피드 이미지 업로드", description = "cloudflare 상에 피드에 사용할 이미지를 업로드하고 주소를 반환받습니다.") public ResponseEntity uploadFeed(@PathVariable String clubId, @RequestPart("feed") MultipartFile file) { return Response.ok(clubImageService.uploadFeed(clubId, file)); } @PostMapping(value = "/{clubId}/feeds") - @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "피드 이미지의 설정을 업데이트 합니다.") + @Operation(summary = "저장된 피드 이미지 업데이트(순서, 삭제 등..)", description = "cloudflare 상에 피드 이미지의 설정을 업데이트 합니다.") public ResponseEntity putFeeds(@PathVariable String clubId, @RequestBody FeedUpdateRequest feeds) { clubImageService.updateFeeds(clubId, feeds.feeds()); return Response.ok("success put feeds"); diff --git a/backend/src/main/java/moadong/gcs/domain/FileType.java b/backend/src/main/java/moadong/media/domain/FileType.java similarity index 89% rename from backend/src/main/java/moadong/gcs/domain/FileType.java rename to backend/src/main/java/moadong/media/domain/FileType.java index ef41ae9c3..910cd6422 100644 --- a/backend/src/main/java/moadong/gcs/domain/FileType.java +++ b/backend/src/main/java/moadong/media/domain/FileType.java @@ -1,4 +1,4 @@ -package moadong.gcs.domain; +package moadong.media.domain; import lombok.Getter; diff --git a/backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java similarity index 75% rename from backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java rename to backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java index dc16934bf..6a920d24f 100644 --- a/backend/src/main/java/moadong/gcs/dto/FeedUpdateRequest.java +++ b/backend/src/main/java/moadong/media/dto/FeedUpdateRequest.java @@ -1,4 +1,4 @@ -package moadong.gcs.dto; +package moadong.media.dto; import java.util.List; diff --git a/backend/src/main/java/moadong/media/service/CloudflareImageService.java b/backend/src/main/java/moadong/media/service/CloudflareImageService.java new file mode 100644 index 000000000..9aa289877 --- /dev/null +++ b/backend/src/main/java/moadong/media/service/CloudflareImageService.java @@ -0,0 +1,172 @@ +package moadong.media.service; + +import static moadong.media.util.ClubImageUtil.containsInvalidChars; +import static moadong.media.util.ClubImageUtil.isImageExtension; +import static moadong.media.util.ClubImageUtil.resizeImage; + +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.global.util.ObjectIdConverter; +import moadong.global.util.RandomStringUtil; +import moadong.media.domain.FileType; +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.ObjectCannedACL; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Service("cloudflare") +@RequiredArgsConstructor +public class CloudflareImageService implements ClubImageService{ + + private final ClubRepository clubRepository; + + private final S3Client s3Client; + + @Value("${server.feed.max-count}") + private int MAX_FEED_COUNT; + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + @Value("${cloud.aws.s3.view-endpoint}") + private String viewEndpoint; + @Value("${server.image.max-size}") + private long MAX_SIZE; + + @Override + public String uploadLogo(String clubId, MultipartFile file) { + Club club = getClub(clubId); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + + String filePath = uploadFile(clubId, file, FileType.LOGO); + club.updateLogo(filePath); + clubRepository.save(club); + return filePath; + } + + @Override + public void deleteLogo(String clubId) { + Club club = getClub(clubId); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + club.updateLogo(null); + clubRepository.save(club); + } + + @Override + public String uploadFeed(String clubId, MultipartFile file) { + int feedImagesCount = getClub(clubId).getClubRecruitmentInformation().getFeedImages().size(); + + if (feedImagesCount + 1 > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + return uploadFile(clubId, file, FileType.FEED); + } + + @Override + public void updateFeeds(String clubId, List newFeedImageList) { + Club club = getClub(clubId); + + if (newFeedImageList.size() > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + + List feedImages = club.getClubRecruitmentInformation().getFeedImages(); + if (feedImages != null && !feedImages.isEmpty()) { + deleteFeedImages(club, feedImages, newFeedImageList); + } + club.updateFeedImages(newFeedImageList); + clubRepository.save(club); + + } + + private Club getClub(String clubId) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + return clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + } + + private void deleteFeedImages(Club club, List feedImages, List newFeedImages) { + for (String feedsImage : feedImages) { + if (!newFeedImages.contains(feedsImage)) { + deleteFile(club, feedsImage); + } + } + } + + @Override + public void deleteFile(Club club, String filePath) { + // https://pub-8655aea549d544239ad12d0385aa98aa.r2.dev/{key} -> {key} + String key = filePath.substring(viewEndpoint.length()+1); + + DeleteObjectRequest deleteRequest = DeleteObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + s3Client.deleteObject(deleteRequest); + } + + private String uploadFile(String clubId, MultipartFile file, FileType fileType) { + if (file == null || file.isEmpty()) { + throw new RestApiException(ErrorCode.FILE_NOT_FOUND); + } + + // 파일명 처리 + String fileName = file.getOriginalFilename(); + + if (!isImageExtension(fileName)) { + throw new RestApiException(ErrorCode.UNSUPPORTED_FILE_TYPE); + } + if (containsInvalidChars(fileName)) { + fileName = RandomStringUtil.generateRandomString(10); + } + if (file.getSize() > MAX_SIZE) { + try { + file = resizeImage(file, MAX_SIZE); + } catch (IOException e) { + throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); + } + } + + // S3에 저장할 key 경로 생성 + String key = clubId + "/" + fileType + "/" + fileName; + + // S3 업로드 요청 + try { + PutObjectRequest putRequest = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(file.getContentType()) + .acl(ObjectCannedACL.PUBLIC_READ) // 공개 URL 용도 + .build(); + + s3Client.putObject(putRequest, RequestBody.fromInputStream( + file.getInputStream(), + file.getSize() + )); + + } catch (IOException e) { + throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); + } catch (Exception e) { + throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED); + } + + // 공유 가능한 공개 URL 반환 + return viewEndpoint + "/" + key; + } + +} diff --git a/backend/src/main/java/moadong/media/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/ClubImageService.java new file mode 100644 index 000000000..857dfa9e2 --- /dev/null +++ b/backend/src/main/java/moadong/media/service/ClubImageService.java @@ -0,0 +1,19 @@ +package moadong.media.service; + +import java.util.List; +import moadong.club.entity.Club; +import org.springframework.web.multipart.MultipartFile; + +public interface ClubImageService { + + String uploadLogo(String clubId, MultipartFile file); + + void deleteLogo(String clubId); + + String uploadFeed(String clubId, MultipartFile file); + + void updateFeeds(String clubId, List newFeedImageList); + + void deleteFile(Club club, String filePath); + +} diff --git a/backend/src/main/java/moadong/gcs/service/ClubImageService.java b/backend/src/main/java/moadong/media/service/GcsClubImageService.java similarity index 90% rename from backend/src/main/java/moadong/gcs/service/ClubImageService.java rename to backend/src/main/java/moadong/media/service/GcsClubImageService.java index 99f5c774b..79ef02902 100644 --- a/backend/src/main/java/moadong/gcs/service/ClubImageService.java +++ b/backend/src/main/java/moadong/media/service/GcsClubImageService.java @@ -1,37 +1,38 @@ -package moadong.gcs.service; +package moadong.media.service; + +import static moadong.media.util.ClubImageUtil.containsInvalidChars; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; -import java.text.Normalizer; import java.util.List; -import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import moadong.club.entity.Club; import moadong.club.repository.ClubRepository; -import moadong.gcs.domain.FileType; import moadong.global.exception.ErrorCode; import moadong.global.exception.RestApiException; import moadong.global.util.ObjectIdConverter; import moadong.global.util.RandomStringUtil; +import moadong.media.domain.FileType; import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -@Service +//@Service @RequiredArgsConstructor -public class ClubImageService { +public class GcsClubImageService implements ClubImageService { private final ClubRepository clubRepository; @Value("${google.cloud.storage.bucket.name}") private String bucketName; - private final Storage storage; - private final int MAX_FEED_COUNT = 5; + @Value("${server.feed.max-count}") + private int MAX_FEED_COUNT; + private final Storage storage; + @Override public String uploadLogo(String clubId, MultipartFile file) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -47,6 +48,7 @@ public String uploadLogo(String clubId, MultipartFile file) { return filePath; } + @Override public void deleteLogo(String clubId) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -57,6 +59,7 @@ public void deleteLogo(String clubId) { } } + @Override public String uploadFeed(String clubId, MultipartFile file) { ObjectId objectId = ObjectIdConverter.convertString(clubId); int feedImagesCount = clubRepository.findClubById(objectId) @@ -69,6 +72,7 @@ public String uploadFeed(String clubId, MultipartFile file) { return uploadFile(clubId, file, FileType.FEED); } + @Override public void updateFeeds(String clubId, List newFeedImageList) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) @@ -94,7 +98,6 @@ private void deleteFeedImages(Club club, List feedImages, List n } } - // TODO : Signed URL 을 통한 업로드 URL 반환으로 추후 변경 private String uploadFile(String clubId, MultipartFile file, FileType fileType) { if (file == null) { throw new RestApiException(ErrorCode.FILE_NOT_FOUND); @@ -110,6 +113,7 @@ private String uploadFile(String clubId, MultipartFile file, FileType fileType) return "https://storage.googleapis.com/" + bucketName + "/" + blobInfo.getName(); } + @Override public void deleteFile(Club club, String filePath) { // 삭제할 파일의 BlobId를 생성 BlobId blobId = BlobId.of(bucketName,splitPath(filePath)); @@ -136,7 +140,7 @@ private BlobInfo getBlobInfo(String clubId, FileType fileType, MultipartFile fil String originalFileName = file.getOriginalFilename(); String contentType = file.getContentType().split("/")[1]; - if (containsKorean(originalFileName)) { + if (containsInvalidChars(originalFileName)) { originalFileName = RandomStringUtil.generateRandomString(10) + "." + contentType; } @@ -152,9 +156,4 @@ private String splitPath(String path) { return path.split("/",5)[4]; } - private boolean containsKorean(String text) { - text = Normalizer.normalize(text, Normalizer.Form.NFC); - return Pattern.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*", text); - } - } diff --git a/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java b/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java new file mode 100644 index 000000000..73055a29e --- /dev/null +++ b/backend/src/main/java/moadong/media/service/GoogleDriveClubImageService.java @@ -0,0 +1,173 @@ +package moadong.media.service; + +import static moadong.media.util.ClubImageUtil.containsInvalidChars; + +import com.google.api.client.http.FileContent; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.model.File; +import com.google.api.services.drive.model.Permission; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.global.util.ObjectIdConverter; +import moadong.global.util.RandomStringUtil; +import moadong.media.domain.FileType; +import org.bson.types.ObjectId; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service("googleDrive") +@RequiredArgsConstructor +public class GoogleDriveClubImageService implements ClubImageService { + + @Value("${google.drive.share-file-id}") + String shareFileId; + @Value("${server.feed.max-count}") + private int MAX_FEED_COUNT; + + private final Drive googleDrive; + private final ClubRepository clubRepository; + + private final String PREFIX = "https://drive.google.com/file/d/"; + private final String SUFFIX = "/view"; + + @Override + public String uploadLogo(String clubId, MultipartFile file) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + + String filePath = uploadFile(clubId, file, FileType.LOGO); + club.updateLogo(filePath); + clubRepository.save(club); + return filePath; + } + + @Override + public void deleteLogo(String clubId) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (club.getClubRecruitmentInformation().getLogo() != null) { + deleteFile(club, club.getClubRecruitmentInformation().getLogo()); + } + club.updateLogo(null); + clubRepository.save(club); + } + + @Override + public String uploadFeed(String clubId, MultipartFile file) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + int feedImagesCount = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)) + .getClubRecruitmentInformation().getFeedImages().size(); + + if (feedImagesCount + 1 > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + return uploadFile(clubId, file, FileType.FEED); + } + + @Override + public void updateFeeds(String clubId, List newFeedImageList) { + ObjectId objectId = ObjectIdConverter.convertString(clubId); + Club club = clubRepository.findClubById(objectId) + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + + if (newFeedImageList.size() > MAX_FEED_COUNT) { + throw new RestApiException(ErrorCode.TOO_MANY_FILES); + } + + List feedImages = club.getClubRecruitmentInformation().getFeedImages(); + if (feedImages != null && !feedImages.isEmpty()) { + deleteFeedImages(club, feedImages, newFeedImageList); + } + club.updateFeedImages(newFeedImageList); + clubRepository.save(club); + } + + private void deleteFeedImages(Club club, List feedImages, List newFeedImages) { + for (String feedsImage : feedImages) { + if (!newFeedImages.contains(feedsImage)) { + deleteFile(club, feedsImage); + } + } + } + + @Override + public void deleteFile(Club club, String filePath) { + //"https://drive.google.com/file/d/{fileId}/view" -> {fileId} + String fileId = filePath.split("/")[5]; + try { + googleDrive.files() + .delete(fileId) + .setSupportsAllDrives(true) // 공유 드라이브(Shared Drive)도 지원할 경우 + .execute(); + } catch (IOException e) { + throw new RestApiException(ErrorCode.IMAGE_DELETE_FAILED); + } + } + + private String uploadFile(String clubId, MultipartFile file, FileType fileType) { + if (file == null) { + throw new RestApiException(ErrorCode.FILE_NOT_FOUND); + } + // MultipartFile → java.io.File 변환 + java.io.File tempFile; + try { + tempFile = java.io.File.createTempFile("upload-", file.getOriginalFilename()); + file.transferTo(tempFile); + } catch (IOException e) { + throw new RestApiException(ErrorCode.FILE_TRANSFER_ERROR); + } + + // 메타데이터 생성 + File fileMetadata = new File(); + String fileName = file.getOriginalFilename(); + if (containsInvalidChars(fileName)) { + fileName = RandomStringUtil.generateRandomString(10); + } + + fileMetadata.setName(clubId + "/" + fileType + "/" + fileName); + fileMetadata.setMimeType(file.getContentType()); + // 공유 ID 설정 + fileMetadata.setParents(Collections.singletonList(shareFileId)); + + // 파일 업로드 + FileContent mediaContent = new FileContent(file.getContentType(), tempFile); + // 전체 공개 권한 설정 + Permission publicPermission = new Permission() + .setType("anyone") // 누구나 + .setRole("reader"); // 읽기 권한 + + File uploadedFile; + try { + uploadedFile= googleDrive.files().create(fileMetadata, mediaContent) + .setFields("id") + .execute(); + + googleDrive.permissions().create(uploadedFile.getId(), publicPermission) + .setFields("id") + .execute(); + } catch (Exception e) { + throw new RestApiException(ErrorCode.IMAGE_UPLOAD_FAILED); + }finally { + // 임시 파일 삭제 + tempFile.delete(); + } + // 공유 링크 반환 + return PREFIX + uploadedFile.getId() + SUFFIX; + } + +} diff --git a/backend/src/main/java/moadong/media/util/ClubImageUtil.java b/backend/src/main/java/moadong/media/util/ClubImageUtil.java new file mode 100644 index 000000000..2d3088ef0 --- /dev/null +++ b/backend/src/main/java/moadong/media/util/ClubImageUtil.java @@ -0,0 +1,62 @@ +package moadong.media.util; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.text.Normalizer; +import java.util.Set; +import java.util.regex.Pattern; +import net.coobird.thumbnailator.Thumbnails; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +public class ClubImageUtil { + + private static final Set ALLOWED_IMAGE_EXTENSIONS = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp"); + + public static boolean containsInvalidChars(String text) { + text = Normalizer.normalize(text, Normalizer.Form.NFC); + return Pattern.matches(".*(%[0-9A-Fa-f]{2}|[ㄱ-ㅎㅏ-ㅣ가-힣]|\\s).*", text); + } + + public static boolean isImageExtension(String originalFilename) { + if (originalFilename == null || !originalFilename.contains(".")) { + return false; + } + String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase(); + return ALLOWED_IMAGE_EXTENSIONS.contains(extension); + } + + public static MultipartFile resizeImage(MultipartFile file, long maxSizeBytes) throws IOException { + double quality = 0.9; + int maxDim = 2000; + byte[] result; + + while (true) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Thumbnails.of(file.getInputStream()) + .size(maxDim, maxDim) + .outputQuality(quality) + .outputFormat("jpg") // 용량 줄이기 좋음 + .toOutputStream(baos); + + result = baos.toByteArray(); + + if (result.length <= maxSizeBytes || (quality <= 0.3 && maxDim <= 800)) { + break; + } + quality -= 0.1; + maxDim -= 200; + file = new MockMultipartFile(file.getName(), file.getOriginalFilename(), + file.getContentType(), new ByteArrayInputStream(file.getBytes())); + } + + return new MockMultipartFile( + file.getName(), + file.getOriginalFilename(), + "image/jpeg", + new ByteArrayInputStream(result) + ); + } + +} diff --git a/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java b/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java new file mode 100644 index 000000000..ad0bc5fff --- /dev/null +++ b/backend/src/main/java/moadong/media/util/GoogleDriveConfig.java @@ -0,0 +1,43 @@ +package moadong.media.util; + +import com.google.api.client.googleapis.auth.oauth2.GoogleCredential; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.jackson2.JacksonFactory; +import com.google.api.services.drive.Drive; +import com.google.api.services.drive.DriveScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import java.io.InputStream; +import java.util.Collections; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.ResourceUtils; + +@Configuration +public class GoogleDriveConfig { + + @Value("${spring.cloud.gcp.credentials.location}") + private String credentialsLocation; + + @Value("${google.application.name}") + private String applicationName; + + @Bean + public Drive googleDriveService() throws Exception { + InputStream in = ResourceUtils.getURL(credentialsLocation).openStream(); + GoogleCredentials credentials = GoogleCredentials.fromStream(in) + .createScoped(Collections.singleton(DriveScopes.DRIVE)); + + return new Drive.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + JacksonFactory.getDefaultInstance(), + new HttpCredentialsAdapter(credentials)) + .setApplicationName(applicationName) + .build(); + + } + + +} diff --git a/backend/src/main/java/moadong/media/util/S3Config.java b/backend/src/main/java/moadong/media/util/S3Config.java new file mode 100644 index 000000000..08f5b9587 --- /dev/null +++ b/backend/src/main/java/moadong/media/util/S3Config.java @@ -0,0 +1,40 @@ +package moadong.media.util; + +import com.google.storage.v2.ListBucketsResponse; +import jakarta.annotation.PostConstruct; +import java.net.URI; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +@Configuration +public class S3Config { + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.s3.endpoint}") + private String endpoint; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .endpointOverride(URI.create(endpoint)) + .region(Region.US_EAST_1) // Region은 아무거나 넣어도 되지만 필수 + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) // Cloudflare R2에서 필수 + .build()) + .build(); + } + +} + diff --git a/backend/src/main/java/moadong/user/entity/User.java b/backend/src/main/java/moadong/user/entity/User.java index 8778f7c9c..a5f2ca22d 100644 --- a/backend/src/main/java/moadong/user/entity/User.java +++ b/backend/src/main/java/moadong/user/entity/User.java @@ -51,6 +51,9 @@ public class User implements UserDetails { @Field("refreshToken") private RefreshToken refreshToken; + @Field("userInformation") + private UserInformation userInformation; + @Builder.Default @NotNull private UserStatus status = UserStatus.ACTIVE; diff --git a/backend/src/main/java/moadong/user/entity/UserInformation.java b/backend/src/main/java/moadong/user/entity/UserInformation.java index 9ff263810..9db45316d 100644 --- a/backend/src/main/java/moadong/user/entity/UserInformation.java +++ b/backend/src/main/java/moadong/user/entity/UserInformation.java @@ -17,17 +17,19 @@ @Builder @AllArgsConstructor @NoArgsConstructor -@Document("user_informations") public class UserInformation { @Id private String id; - @NotNull - @Indexed(unique = true) - private String userId; + @NotNull @Korean private String name; @PhoneNumber private String phoneNumber; + + public UserInformation(String name, String phoneNumber) { + this.name = name; + this.phoneNumber = phoneNumber; + } } diff --git a/backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java b/backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java index 6cbc14506..65c1fa3a3 100644 --- a/backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java +++ b/backend/src/main/java/moadong/user/payload/request/UserRegisterRequest.java @@ -1,36 +1,47 @@ package moadong.user.payload.request; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import moadong.global.annotation.Korean; import moadong.global.annotation.Password; import moadong.global.annotation.PhoneNumber; import moadong.global.annotation.UserId; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; import moadong.user.entity.User; import moadong.user.entity.UserInformation; +import org.springframework.security.crypto.password.PasswordEncoder; public record UserRegisterRequest( - @NotNull + @NotBlank @UserId String userId, - @NotNull + @NotBlank @Password String password, - @NotNull + @NotBlank @Korean String name, @PhoneNumber String phoneNumber ) { - public User toUserEntity(String password) { + public UserRegisterRequest{ + if (userId.equals(password)) { + throw new RestApiException(ErrorCode.USER_INVALID_FORMAT); + } + } + + public User toUserEntity(PasswordEncoder passwordEncoder) { return User.builder() .userId(userId) - .password(password) + .password(passwordEncoder.encode(password)) + .userInformation(new UserInformation(name,phoneNumber)) .build(); } public UserInformation toUserInformationEntity(String userId) { return UserInformation.builder() - .userId(userId) .name(name) .phoneNumber(phoneNumber) .build(); diff --git a/backend/src/main/java/moadong/user/repository/UserInformationRepository.java b/backend/src/main/java/moadong/user/repository/UserInformationRepository.java deleted file mode 100644 index fb388e3d9..000000000 --- a/backend/src/main/java/moadong/user/repository/UserInformationRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package moadong.user.repository; - -import moadong.user.entity.UserInformation; -import org.springframework.data.mongodb.repository.MongoRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface UserInformationRepository extends MongoRepository { -} diff --git a/backend/src/main/java/moadong/user/service/UserCommandService.java b/backend/src/main/java/moadong/user/service/UserCommandService.java index b0690aae3..adab85964 100644 --- a/backend/src/main/java/moadong/user/service/UserCommandService.java +++ b/backend/src/main/java/moadong/user/service/UserCommandService.java @@ -2,6 +2,7 @@ import com.mongodb.MongoWriteException; import jakarta.servlet.http.HttpServletResponse; +import java.util.Date; import lombok.AllArgsConstructor; import moadong.club.entity.Club; import moadong.club.repository.ClubRepository; @@ -16,7 +17,6 @@ import moadong.user.payload.request.UserUpdateRequest; import moadong.user.payload.response.LoginResponse; import moadong.user.payload.response.RefreshResponse; -import moadong.user.repository.UserInformationRepository; import moadong.user.repository.UserRepository; import moadong.user.util.CookieMaker; import org.springframework.http.ResponseCookie; @@ -26,43 +26,38 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import java.util.Date; - @Service @AllArgsConstructor public class UserCommandService { private final UserRepository userRepository; - private final UserInformationRepository userInformationRepository; private final AuthenticationManager authenticationManager; private final JwtProvider jwtProvider; private final PasswordEncoder passwordEncoder; private final ClubRepository clubRepository; private final CookieMaker cookieMaker; - public void registerUser(UserRegisterRequest userRegisterRequest) { + public User registerUser(UserRegisterRequest userRegisterRequest) { try { - String encodedPw = passwordEncoder.encode(userRegisterRequest.password()); - User user = userRepository.save(userRegisterRequest.toUserEntity(encodedPw)); - userInformationRepository.save( - userRegisterRequest.toUserInformationEntity(user.getId())); + User user = userRepository.save(userRegisterRequest.toUserEntity(passwordEncoder)); createClub(user.getId()); + return user; } catch (MongoWriteException e) { throw new RestApiException(ErrorCode.USER_ALREADY_EXIST); } } public LoginResponse loginUser(UserLoginRequest userLoginRequest, - HttpServletResponse response) { + HttpServletResponse response) { try { Authentication authenticate = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(userLoginRequest.userId(), - userLoginRequest.password())); + new UsernamePasswordAuthenticationToken(userLoginRequest.userId(), + userLoginRequest.password())); CustomUserDetails userDetails = (CustomUserDetails) authenticate.getPrincipal(); Club club = clubRepository.findClubByUserId(userDetails.getId()) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); User user = userRepository.findUserByUserId(userDetails.getUserId()) - .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); String accessToken = jwtProvider.generateAccessToken(userDetails.getUsername()); RefreshToken refreshToken = jwtProvider.generateRefreshToken(userDetails.getUsername()); @@ -80,24 +75,24 @@ public LoginResponse loginUser(UserLoginRequest userLoginRequest, public void logoutUser(String refreshToken) { User user = userRepository.findUserByRefreshToken_Token(refreshToken) - .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); user.updateRefreshToken(null); userRepository.save(user); } public RefreshResponse refreshAccessToken(String refreshToken, - HttpServletResponse response) { + HttpServletResponse response) { if (refreshToken.isBlank() || - !jwtProvider.validateToken(refreshToken, jwtProvider.extractUsername(refreshToken))) { + !jwtProvider.validateToken(refreshToken, jwtProvider.extractUsername(refreshToken))) { throw new RestApiException(ErrorCode.TOKEN_INVALID); } String userId = jwtProvider.extractUsername(refreshToken); User user = userRepository.findUserByUserId(userId) - .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); if (!user.getRefreshToken().getToken().equals(refreshToken) - || jwtProvider.isTokenExpired(refreshToken)) { + || jwtProvider.isTokenExpired(refreshToken)) { throw new RestApiException(ErrorCode.TOKEN_INVALID); } String accessToken = jwtProvider.generateAccessToken(userId); @@ -112,10 +107,10 @@ public RefreshResponse refreshAccessToken(String refreshToken, } public void update(String userId, - UserUpdateRequest userUpdateRequest, - HttpServletResponse response) { + UserUpdateRequest userUpdateRequest, + HttpServletResponse response) { User user = userRepository.findUserByUserId(userId) - .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new RestApiException(ErrorCode.USER_NOT_EXIST)); user.updateUserProfile(userUpdateRequest.encryptPassword(passwordEncoder)); userRepository.save(user); @@ -127,7 +122,7 @@ public void update(String userId, public String findClubIdByUserId(String userID) { Club club = clubRepository.findClubByUserId(userID) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); return club.getId(); } diff --git a/backend/src/test/java/moadong/club/fixture/ClubFixture.java b/backend/src/test/java/moadong/club/fixture/ClubFixture.java new file mode 100644 index 000000000..07a41b38e --- /dev/null +++ b/backend/src/test/java/moadong/club/fixture/ClubFixture.java @@ -0,0 +1,17 @@ +package moadong.club.fixture; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import moadong.club.entity.Club; + +public class ClubFixture { + + public static Club createClub(String clubId, String name) { + Club club = mock(Club.class); + when(club.getId()).thenReturn(clubId); + when(club.getName()).thenReturn(name); + return club; + } + +} diff --git a/backend/src/test/java/moadong/club/fixture/MetricFixture.java b/backend/src/test/java/moadong/club/fixture/MetricFixture.java new file mode 100644 index 000000000..724c13340 --- /dev/null +++ b/backend/src/test/java/moadong/club/fixture/MetricFixture.java @@ -0,0 +1,31 @@ +package moadong.club.fixture; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import moadong.club.entity.ClubMetric; + +public class MetricFixture { + + public static ClubMetric createClubMetric(LocalDate date) { + ClubMetric metric = mock(ClubMetric.class); + when(metric.getDate()).thenReturn(date); + return metric; + } + + public static ClubMetric createClubMetric(String clubId, LocalDate date) { + ClubMetric metric = mock(ClubMetric.class); + when(metric.getClubId()).thenReturn(clubId); + when(metric.getDate()).thenReturn(date); + return metric; + } + + public static ClubMetric createClubMetric(LocalDate date, String ip) { + ClubMetric metric = mock(ClubMetric.class); + when(metric.getDate()).thenReturn(date); + when(metric.getIp()).thenReturn(ip); + return metric; + } + +} diff --git a/backend/src/test/java/moadong/club/service/ClubMetricServiceTest.java b/backend/src/test/java/moadong/club/service/ClubMetricServiceTest.java new file mode 100644 index 000000000..fa72d8216 --- /dev/null +++ b/backend/src/test/java/moadong/club/service/ClubMetricServiceTest.java @@ -0,0 +1,240 @@ +package moadong.club.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoField; +import java.util.List; +import java.util.Optional; +import moadong.club.entity.Club; +import moadong.club.entity.ClubMetric; +import moadong.club.fixture.ClubFixture; +import moadong.club.fixture.MetricFixture; +import moadong.club.repository.ClubMetricRepository; +import moadong.club.repository.ClubRepository; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +@SpringBootTest +@SuppressWarnings("NonAsciiCharacters") +public class ClubMetricServiceTest { + + @Autowired + private ClubMetricService clubMetricService; + + @MockBean + private ClubRepository clubRepository; + + @MockBean + private ClubMetricRepository clubMetricRepository; + + @Test + void 메트릭이_이미_존재한다면_최신화() { + // given + String clubId = "club-1"; + String ip = "192.168.0.1"; + LocalDate today = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + + ClubMetric existingMetric = Mockito.mock(ClubMetric.class); + + when(clubMetricRepository.findByClubIdAndIpAndDate(clubId, ip, today)) + .thenReturn(Optional.of(existingMetric)); + + // when + clubMetricService.patch(clubId, ip); + + // then + verify(existingMetric).update(); + verify(clubMetricRepository).save(existingMetric); + } + + @Test + void 일일_활성_사용자수_검증() { + // given + String clubId = "club-1"; + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + + List metrics = List.of( + MetricFixture.createClubMetric(now.minusDays(1)), + MetricFixture.createClubMetric(now.minusDays(3)), + MetricFixture.createClubMetric(now.minusDays(3)), + MetricFixture.createClubMetric(now.minusDays(29)) + ); + + when(clubMetricRepository.findByClubIdAndDateAfter(eq(clubId), eq(now.minusDays(30)))) + .thenReturn(metrics); + + // when + int[] result = clubMetricService.getDailyActiveUserWitClub(clubId); + + // then + assertEquals(1, result[1]); + assertEquals(2, result[3]); + assertEquals(1, result[29]); + + // 나머지 날짜는 0이어야 함 + for (int i = 0; i < result.length; i++) { + if (i != 1 && i != 3 && i != 29) { + assertEquals(0, result[i], "Expected 0 at index " + i); + } + } + } + + @Test + void 주간_활성_사용자수_검증() { + // given + String clubId = "club-1"; + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + LocalDate thisWeekMonday = now.with(ChronoField.DAY_OF_WEEK, 1); + + List metrics = List.of( + MetricFixture.createClubMetric(thisWeekMonday.minusWeeks(1).plusDays(2)), + MetricFixture.createClubMetric(thisWeekMonday.minusWeeks(3)), + MetricFixture.createClubMetric(thisWeekMonday.minusWeeks(3).plusDays(5)), + MetricFixture.createClubMetric(thisWeekMonday.minusWeeks(11)) + ); + + when(clubMetricRepository.findByClubIdAndDateAfter(eq(clubId), eq(now.minusDays(84)))) + .thenReturn(metrics); + + // when + int[] result = clubMetricService.getWeeklyActiveUserWitClub(clubId); + + // then + assertEquals(1, result[1]); + assertEquals(2, result[3]); + assertEquals(1, result[11]); + + for (int i = 0; i < result.length; i++) { + if (i != 1 && i != 3 && i != 11) { + assertEquals(0, result[i], "Expected 0 at index " + i); + } + } + } + + @Test + void 월간_활성_사용자수_검증() { + // given + String clubId = "club-1"; + LocalDate now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + YearMonth currentMonth = YearMonth.from(now); + + List metrics = List.of( + MetricFixture.createClubMetric(currentMonth.minusMonths(1).atDay(10)), + MetricFixture.createClubMetric(currentMonth.minusMonths(3).atDay(5)), + MetricFixture.createClubMetric(currentMonth.minusMonths(3).atEndOfMonth()), + MetricFixture.createClubMetric(currentMonth.minusMonths(11).atDay(15)) + ); + + when(clubMetricRepository.findByClubIdAndDateAfter(eq(clubId), + eq(currentMonth.minusMonths(12).atDay(1)))) + .thenReturn(metrics); + + // when + int[] result = clubMetricService.getMonthlyActiveUserWitClub(clubId); + + // then + assertEquals(1, result[1]); + assertEquals(2, result[3]); + assertEquals(1, result[11]); + + for (int i = 0; i < result.length; i++) { + if (i != 1 && i != 3 && i != 11) { + assertEquals(0, result[i], "Expected 0 at index " + i); + } + } + } + + @Nested + class getDailyRanking { + + @Test + void 일부_Club_정보가_누락되어도_null_포함하여_정상_동작() { + // given + LocalDate today = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + + List metrics = List.of( + MetricFixture.createClubMetric("club1", today), + MetricFixture.createClubMetric("club1", today), + MetricFixture.createClubMetric("club2", today) + ); + + when(clubMetricRepository.findAllByDate(eq(today))).thenReturn(metrics); + + Club club1 = ClubFixture.createClub("club1", "클럽1"); + when(clubRepository.findAllById(List.of("club1", "club2"))).thenReturn( + List.of(club1)); //club2 누락 + + // when + List ranking = clubMetricService.getDailyRanking(2); + + // then + assertEquals(2, ranking.size()); + assertEquals("클럽1", ranking.get(0)); + assertNull(ranking.get(1)); + } + + @Test + void 반드시_1개_이상의_동아리를_조회해야_한다() { + // when + List ranking = clubMetricService.getDailyRanking(0); + + // then + assertTrue(ranking.isEmpty()); + } + } + + @Nested + class getDailyActiveUser { + + @Test + void 하루_미만의_사용자수_요청시_빈배열을_반환한다() { + //when + int[] result = clubMetricService.getDailyActiveUser(-2); + + //then + assertEquals(0, result.length); + } + + @Test + void ip_중복을_제거하여_카운트_한다() { + // given + int n = 3; + LocalDate today = ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDate(); + + List metrics = List.of( + MetricFixture.createClubMetric(today.minusDays(0), "192.0.2.1"), + MetricFixture.createClubMetric(today.minusDays(0), "192.0.2.2"), + MetricFixture.createClubMetric(today.minusDays(0), "192.0.2.1"), + MetricFixture.createClubMetric(today.minusDays(1), "192.0.2.3"), + MetricFixture.createClubMetric(today.minusDays(2), "192.0.2.4"), + MetricFixture.createClubMetric(today.minusDays(2), "192.0.2.5") + ); + + when(clubMetricRepository.findAllByDateAfter(today.minusDays(n))) + .thenReturn(metrics); + + // when + int[] result = clubMetricService.getDailyActiveUser(n); + + // then + assertEquals(3, result.length); + assertEquals(2, result[0]); + assertEquals(1, result[1]); + assertEquals(2, result[2]); + } + } + +} diff --git a/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java new file mode 100644 index 000000000..5a184fc9d --- /dev/null +++ b/backend/src/test/java/moadong/club/service/ClubSearchServiceTest.java @@ -0,0 +1,106 @@ +package moadong.club.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import java.util.List; +import moadong.club.payload.dto.ClubSearchResult; +import moadong.club.payload.response.ClubSearchResponse; +import moadong.club.repository.ClubSearchRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ClubSearchServiceTest { + + @Mock + private ClubSearchRepository clubSearchRepository; + + @InjectMocks + private ClubSearchService clubSearchService; + + @Test + void 검색조건이_유효하면_모집상태순으로_정렬하여_반환한다() { + // given + String keyword = "동아리"; + String recruitmentStatus = "all"; + String division = "all"; + String category = "all"; // 전체 카테고리 + + ClubSearchResult club1 = ClubSearchResult.builder().name("club1").recruitmentStatus("OPEN") + .division("중동").category("봉사").build(); + ClubSearchResult club2 = ClubSearchResult.builder().name("club2").recruitmentStatus("UPCOMING") + .division("중동").category("종교").build(); + ClubSearchResult club3 = ClubSearchResult.builder().name("club3").recruitmentStatus("CLOSED") + .division("중동").category("공연").build(); + ClubSearchResult club4 = ClubSearchResult.builder().name("club4").recruitmentStatus("ALWAYS") + .division("중동").category("운동").build(); + + List unsorted = List.of(club1, club2, club3,club4); + + when(clubSearchRepository.searchClubsByKeyword(keyword, recruitmentStatus, division, category)) + .thenReturn(unsorted); + + // when + ClubSearchResponse response = clubSearchService.searchClubsByKeyword(keyword, recruitmentStatus, division, category); + + // then + List sorted = response.clubs(); + assertIterableEquals(sorted, List.of(club4, club1, club3, club2)); + } + + @Test + void 모집상태에_해당하는_동아리가_없으면_빈_리스트를_반환한다() { + // given + String keyword = "없는키워드"; + String recruitmentStatus = "OPEN"; + String division = "중동"; + String category = "봉사"; + + when(clubSearchRepository.searchClubsByKeyword(keyword, recruitmentStatus, division, category)) + .thenReturn(List.of()); // 빈 리스트 반환 + + // when + ClubSearchResponse response = clubSearchService.searchClubsByKeyword(keyword, recruitmentStatus, division, category); + + // then + assertTrue(response.clubs().isEmpty()); + } + + @Test + void 모집상태가_같다면_카테고리순으로_정렬하고_카테고리도_같다면_이름순으로_반환한다() { + // given + String keyword = "동아리"; + String recruitmentStatus = "all"; + String division = "all"; + String category = "all"; // 전체 카테고리 + + ClubSearchResult club1 = ClubSearchResult.builder().name("club1").recruitmentStatus("OPEN") + .division("중동").category("봉사").build(); + ClubSearchResult club2 = ClubSearchResult.builder().name("club2").recruitmentStatus("OPEN") + .division("중동").category("종교").build(); + ClubSearchResult club3 = ClubSearchResult.builder().name("club3").recruitmentStatus("OPEN") + .division("중동").category("종교").build(); + + List unsorted = List.of(club3, club2, club1); + + when(clubSearchRepository.searchClubsByKeyword(keyword, recruitmentStatus, division, category)) + .thenReturn(unsorted); + + // when + ClubSearchResponse response = clubSearchService.searchClubsByKeyword(keyword, recruitmentStatus, division, category); + + // then + List sorted = response.clubs(); + assertEquals("club1", sorted.get(0).name()); + assertEquals("club2", sorted.get(1).name()); + assertEquals("club3", sorted.get(2).name()); + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java new file mode 100644 index 000000000..fdb54f69f --- /dev/null +++ b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java @@ -0,0 +1,125 @@ +package moadong.club.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.enums.ClubRecruitmentStatus; +import moadong.club.repository.ClubRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class RecruitmentStateCheckerTest { + + @InjectMocks + private RecruitmentStateChecker recruitmentStateChecker; + + @Mock + private ClubRepository clubRepository; + + static final ZonedDateTime NOW = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + @Test + void 모집상태_ALWAYS_이면_변경되지_않음() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.ALWAYS); + + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club, never()).updateRecruitmentStatus(any()); + verify(clubRepository, never()).save(club); + } + + @Test + void 모집시작전_14일이내면_UPCOMING() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.plusDays(10); + ZonedDateTime end = NOW.plusDays(20); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); + verify(clubRepository).save(club); + } + + @Test + void 모집기간중이면_OPEN() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.minusDays(1); + ZonedDateTime end = NOW.plusDays(5); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); + verify(clubRepository).save(club); + } + + @Test + void 모집마감_이후면_CLOSED() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.minusDays(10); + ZonedDateTime end = NOW.minusDays(1); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(clubRepository).save(club); + } + + @Test + void 시작_또는_종료날짜_null이면_CLOSED() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); + when(info.getRecruitmentStart()).thenReturn(null); + when(info.getRecruitmentEnd()).thenReturn(null); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(clubRepository).save(club); + } +} diff --git a/backend/src/test/java/moadong/fixture/ClubFixture.java b/backend/src/test/java/moadong/fixture/ClubFixture.java new file mode 100644 index 000000000..d071b24fd --- /dev/null +++ b/backend/src/test/java/moadong/fixture/ClubFixture.java @@ -0,0 +1,48 @@ +package moadong.fixture; + +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.enums.ClubRecruitmentStatus; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.List; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class ClubFixture { + public static Club createClub(String clubId, String name) { + Club club = mock(Club.class); + when(club.getId()).thenReturn(clubId); + when(club.getName()).thenReturn(name); + return club; + } + + public static ClubRecruitmentInformation createRecruitmentInfo( + String id, + String logo, + String introduction, + String description, + String presidentName, + String presidentTelephoneNumber, + LocalDateTime recruitmentStart, + LocalDateTime recruitmentEnd, + List feedImages, + ClubRecruitmentStatus clubRecruitmentStatus) { + ClubRecruitmentInformation clubRecruitmentInfo = mock(ClubRecruitmentInformation.class); + when(clubRecruitmentInfo.getId()).thenReturn(id); + when(clubRecruitmentInfo.getLogo()).thenReturn(logo); + when(clubRecruitmentInfo.getIntroduction()).thenReturn(introduction); + when(clubRecruitmentInfo.getDescription()).thenReturn(description); + when(clubRecruitmentInfo.getPresidentName()).thenReturn(presidentName); + when(clubRecruitmentInfo.getPresidentTelephoneNumber()).thenReturn(presidentTelephoneNumber); + when(clubRecruitmentInfo.getRecruitmentStart()).thenReturn(ZonedDateTime.from(recruitmentStart)); + when(clubRecruitmentInfo.getRecruitmentEnd()).thenReturn(ZonedDateTime.from(recruitmentEnd)); + when(clubRecruitmentInfo.getFeedImages()).thenReturn(feedImages); + when(clubRecruitmentInfo.getClubRecruitmentStatus()).thenReturn(clubRecruitmentStatus); + return clubRecruitmentInfo; + } + + +} \ No newline at end of file diff --git a/backend/src/test/java/moadong/fixture/ClubRequestFixture.java b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java new file mode 100644 index 000000000..294d64b7b --- /dev/null +++ b/backend/src/test/java/moadong/fixture/ClubRequestFixture.java @@ -0,0 +1,50 @@ +package moadong.fixture; + +import moadong.club.payload.request.ClubInfoRequest; + +import java.util.List; +import java.util.Map; + +public class ClubRequestFixture { + public static ClubInfoRequest createValidClubInfoRequest() { + return new ClubInfoRequest( + "club_123", + "테스트동아리", + "학술", + "프로그래밍", + List.of("개발", "스터디"), + "동아리 소개입니다.", + "홍길동", + "010-1234-5678", + Map.of("insta", "https://test") + ); + + } + //ToDo: 시간 계산법을 LocalDateTime에서 Instant로 변경 후에 활성화할 것 +// public static ClubRecruitmentInfoUpdateRequest createValidRequest() { +// return new ClubRecruitmentInfoUpdateRequest( +// "club_123", +// Instant.now().plus(1, ChronoUnit.DAYS), // 1일 후 +// Instant.now().plus(7, ChronoUnit.DAYS), // 7일 후 +// "1~3학년", +// "새로운 모집 설명입니다." +// ); +// } +// +// // 커스텀 파라미터로 생성 +// public static ClubRecruitmentInfoUpdateRequest createCustomRequest( +// String id, +// Instant start, +// Instant end, +// String target, +// String description +// ) { +// return new ClubRecruitmentInfoUpdateRequest( +// id, +// start, +// end, +// target, +// description +// ); +// } +} diff --git a/backend/src/test/java/moadong/fixture/UserFixture.java b/backend/src/test/java/moadong/fixture/UserFixture.java new file mode 100644 index 000000000..80ec986ca --- /dev/null +++ b/backend/src/test/java/moadong/fixture/UserFixture.java @@ -0,0 +1,29 @@ +package moadong.fixture; + +import moadong.user.entity.User; +import moadong.user.entity.UserInformation; +import moadong.user.payload.CustomUserDetails; +import moadong.user.payload.request.UserRegisterRequest; +import org.springframework.security.crypto.password.PasswordEncoder; + +public class UserFixture { + public static final String collectUserId = "test12345"; + public static final String collectPassword = "test12345@"; + public static final String collectName = "테스터"; + public static final String collectPhoneNumber = "010-1234-5678"; + + public static User createUser(PasswordEncoder passwordEncoder, String userId, String password, String name, String phoneNumber) { + return new UserRegisterRequest(userId, password, name, phoneNumber).toUserEntity(passwordEncoder); + } + + public static CustomUserDetails createUserDetails(String userId) { + return new CustomUserDetails( + User.builder() + .id(userId) + .userInformation(new UserInformation(collectUserId, collectPhoneNumber)) + .password(collectPassword) + .build() + ); + } + +} diff --git a/backend/src/test/java/moadong/fixture/UserRequestFixture.java b/backend/src/test/java/moadong/fixture/UserRequestFixture.java new file mode 100644 index 000000000..5d4448951 --- /dev/null +++ b/backend/src/test/java/moadong/fixture/UserRequestFixture.java @@ -0,0 +1,13 @@ +package moadong.fixture; + +import moadong.user.payload.request.UserLoginRequest; +import moadong.user.payload.request.UserRegisterRequest; + +public class UserRequestFixture { + public static UserRegisterRequest createUserRegisterRequest(String userId, String password, String name, String phoneNumber) { + return new UserRegisterRequest(userId, password, name, phoneNumber); + } + public static UserLoginRequest createUserLoginRequest(String userId, String password){ + return new UserLoginRequest(userId,password); + } +} diff --git a/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceFeedTest.java b/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceFeedTest.java new file mode 100644 index 000000000..c7e07976f --- /dev/null +++ b/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceFeedTest.java @@ -0,0 +1,134 @@ +package moadong.media.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertIterableEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.when; + +import com.google.api.services.drive.Drive; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.util.annotations.UnitTest; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +@UnitTest +class GoogleDriveClubImageServiceFeedTest { + + @Spy + @InjectMocks + private GoogleDriveClubImageService clubImageService; + + @Mock + private Drive googleDrive; + + @Mock + private ClubRepository clubRepository; + + private final int MAX_FEED_COUNT = 5; + + private Club club; + + private ObjectId objectId; + + private final MultipartFile mockFile = new MockMultipartFile( + "testFile", "testFile.jpg", "image/jpeg", "test".getBytes()); + + @BeforeEach + void setUp() { + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .feedImages(List.of()) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + objectId = new ObjectId(); + ReflectionTestUtils.setField(clubImageService, "MAX_FEED_COUNT", 5); + } + + // uploadFeed + @Test + void MAX_FEED_COUNT_이상의_피드를_업로드하면_TOO_MANY_FILES를_반환한다() { + // given + List feedImages = Arrays.asList(new String[MAX_FEED_COUNT]); + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .feedImages(feedImages) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile)); + assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); + } + + @Test + void feed를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { + when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); + assertThrows(RestApiException.class, () -> clubImageService.uploadFeed(objectId.toHexString(), mockFile)); + } + + // updateFeeds + @Test + void 새로운_feed_리스트를_넣으면_정상적으로_저장된다() { + // given + List feedImages = List.of("old1.jpg", "old2.jpg"); + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .feedImages(feedImages) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + List newList = List.of("new1.jpg"); + when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); + doNothing().when(clubImageService).deleteFile(eq(club), any()); + + // when + clubImageService.updateFeeds(objectId.toHexString(), newList); + + // then + assertIterableEquals(newList, club.getClubRecruitmentInformation().getFeedImages()); + + } + + @Test + void feed를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { + // given + when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.updateFeeds(objectId.toHexString(), List.of())); + assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 새로운_feed_리스트가_MAX_FEED_COUNT_이상이면_TOO_MANY_FILES를_반환한다() { + // given + when(clubRepository.findClubById(any())).thenReturn(Optional.of(club)); + List tooMany = Arrays.asList(new String[MAX_FEED_COUNT + 1]); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.updateFeeds(objectId.toHexString(), tooMany)); + assertEquals(ErrorCode.TOO_MANY_FILES, exception.getErrorCode()); + } + +} diff --git a/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceLogoTest.java b/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceLogoTest.java new file mode 100644 index 000000000..eda6adf70 --- /dev/null +++ b/backend/src/test/java/moadong/media/service/GoogleDriveClubImageServiceLogoTest.java @@ -0,0 +1,151 @@ +package moadong.media.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.when; + +import com.google.api.services.drive.Drive; +import java.lang.reflect.Method; +import java.util.Optional; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.ErrorCode; +import moadong.global.exception.RestApiException; +import moadong.media.domain.FileType; +import moadong.media.util.ClubImageUtil; +import moadong.util.annotations.UnitTest; +import org.bson.types.ObjectId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +@ExtendWith(MockitoExtension.class) +class GoogleDriveClubImageServiceLogoTest { + + @Spy + @InjectMocks + private GoogleDriveClubImageService clubImageService; + + @Mock + private Drive googleDrive; + + @Mock + private ClubRepository clubRepository; + + private Club club; + + private ObjectId objectId; + + private final MultipartFile mockFile = new MockMultipartFile( + "testFile", "testFile.jpg", "image/jpeg", "test".getBytes()); + + @BeforeEach + void setUp() throws NoSuchMethodException { + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .logo(null) + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + objectId = new ObjectId(); + } + + // updateLogo + @Test + void 로고를_업데이트하면_uploadFile메서드를_호출하고_예외를_발생시킨다() { + // given + when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); +// doReturn( "https://drive.google.com/file/d/" + club.getId() + "/LOGO/" + mockFile.getOriginalFilename() + "/view" ) +// .when(clubImageService).uploadFile(any(), eq(mockFile), eq(FileType.LOGO)); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.uploadLogo(objectId.toHexString(), mockFile)); + assertEquals(ErrorCode.IMAGE_UPLOAD_FAILED, exception.getErrorCode()); + + } + + @Test + void logo를_업로드할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { + // given + when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.uploadLogo(objectId.toHexString(), mockFile)); + assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 업로드하는_파일이_없다면_FILE_NOT_FOUND를_반환한다() { + // given + when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.uploadLogo(objectId.toHexString(), null)); + assertEquals(ErrorCode.FILE_NOT_FOUND, exception.getErrorCode()); + } + + // deleteLogo + @Test + void 로고를_삭제하면_logo값이_null이_된다() { + // given + ClubRecruitmentInformation info = ClubRecruitmentInformation.builder() + .logo("test link") + .build(); + club = Club.builder().clubRecruitmentInformation(info).build(); + when(clubRepository.findClubById(objectId)).thenReturn(Optional.of(club)); + doNothing().when(clubImageService).deleteFile(club, "test link"); + + // when + clubImageService.deleteLogo(objectId.toHexString()); + + // then + assertNull(club.getClubRecruitmentInformation().getLogo()); + } + + @Test + void logo를_삭제할_club이_존재하지_않는다면_CLUB_NOT_FOUND를_반환한다() { + // given + when(clubRepository.findClubById(objectId)).thenReturn(Optional.empty()); + + // when & then + RestApiException exception = assertThrows(RestApiException.class, + () -> clubImageService.deleteLogo(objectId.toHexString())); + assertEquals(ErrorCode.CLUB_NOT_FOUND, exception.getErrorCode()); + } + + @Test + void 파일명에_적절하지않은_기호나_한글이_포함되어있으면_true를_반환한다(){ + // 정상 케이스 + assertFalse(ClubImageUtil.containsInvalidChars("normal-file.jpg")); + + // 한글 포함 + assertTrue(ClubImageUtil.containsInvalidChars("file 이름.png")); + + // 퍼센트 인코딩 포함 + assertTrue(ClubImageUtil.containsInvalidChars("file%20name.png")); + assertTrue(ClubImageUtil.containsInvalidChars("%3Ahidden.jpg")); + + // 공백 포함 + assertTrue(ClubImageUtil.containsInvalidChars("file name.png")); + assertTrue(ClubImageUtil.containsInvalidChars(" tab\tname.png")); + + // 여러 조건 섞인 경우 + assertTrue(ClubImageUtil.containsInvalidChars("%22한글 space.png")); + } +} diff --git a/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java b/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java new file mode 100644 index 000000000..2aa409294 --- /dev/null +++ b/backend/src/test/java/moadong/unit/club/ClubProfileServiceTest.java @@ -0,0 +1,119 @@ +package moadong.unit.club; + +import static moadong.fixture.UserFixture.createUserDetails; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import moadong.club.entity.Club; +import moadong.club.payload.request.ClubCreateRequest; +import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.repository.ClubRepository; +import moadong.club.service.ClubProfileService; +import moadong.fixture.ClubRequestFixture; +import moadong.global.exception.RestApiException; +import moadong.user.payload.CustomUserDetails; +import moadong.util.annotations.UnitTest; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +@UnitTest +public class ClubProfileServiceTest { + + private final String clubId = "club_123"; + private final String userId = "user_456"; + @Mock + private ClubRepository clubRepository; + @InjectMocks + private ClubProfileService clubProfileService; + + @Test + public void 정상적으로_클럽을_생성한다() { + // Given + ClubCreateRequest request = new ClubCreateRequest("테스트", "카테고리", "분과"); + when(clubRepository.save(any())).thenReturn(mock(Club.class)); + + // When + clubProfileService.createClub(request); + + // Then + verify(clubRepository, times(1)).save(any(Club.class)); + } + + @Test + void 정상적으로_클럽_약력을_업데이트한다() { + // Given + ClubInfoRequest request = ClubRequestFixture.createValidClubInfoRequest(); + CustomUserDetails user = createUserDetails(userId); + Club mockClub = mock(Club.class); + + when(clubRepository.findById(clubId)).thenReturn(Optional.of(mockClub)); + when(mockClub.getUserId()).thenReturn(userId); + + // When + clubProfileService.updateClubInfo(request, user); + + // Then + verify(mockClub).update(request); + verify(clubRepository).save(mockClub); + } + + @Test + void 클럽이_없을_땐_클럽_약력_업데이트가_실패한다() { + when(clubRepository.findById(any())).thenReturn(Optional.empty()); + assertThrows(RestApiException.class, + () -> clubProfileService.updateClubInfo(ClubRequestFixture.createValidClubInfoRequest(), + createUserDetails(userId))); + } + + @Test + void 권한이_없는_클럽은_클럽_약력_업데이트_할_수_없다() { + Club mockClub = mock(Club.class); + when(clubRepository.findById(clubId)).thenReturn(Optional.of(mockClub)); + when(mockClub.getUserId()).thenReturn("different_user"); + + assertThrows(RestApiException.class, + () -> clubProfileService.updateClubInfo(ClubRequestFixture.createValidClubInfoRequest(), + createUserDetails(userId))); + } + +// ToDo: 시간 계산법을 LocalDateTime에서 Instant로 변경 후에 활성화할 것 +// @Test +// void updateClubRecruitmentInfo_WithDates_SchedulesRecruitment() { +// // Given +// Instant start = Instant.now().plus(1, ChronoUnit.DAYS); +// Instant end = Instant.now().plus(7,ChronoUnit.DAYS); +// ClubRecruitmentInfoUpdateRequest request = new ClubRecruitmentInfoUpdateRequest( +// "testId",start,end,"모집대상테스트","테스트용 설명" ); +// Club mockClub = mock(Club.class); +// +// when(clubRepository.findById(clubId)).thenReturn(Optional.of(mockClub)); +// when(mockClub.getUserId()).thenReturn(userId); +// +// // When +// clubProfileService.updateClubRecruitmentInfo(request, createUserDetails(userId)); +// +// // Then +// verify(recruitmentScheduler).scheduleRecruitment(clubId, start, end); +// verify(mockClub).update(request); +// } +// +// @Test +// void updateClubRecruitmentInfo_NoDates_NoScheduling() { +// // Given +// ClubRecruitmentInfoUpdateRequest request = new ClubRecruitmentInfoUpdateRequest( +// clubId, null, null, /* other fields */); +// +// // When +// clubProfileService.updateClubRecruitmentInfo(request, createUserDetails(userId)); +// +// // Then +// verifyNoInteractions(recruitmentScheduler); +// } + +} diff --git a/backend/src/test/java/moadong/unit/user/PasswordValidatorTest.java b/backend/src/test/java/moadong/unit/user/PasswordValidatorTest.java new file mode 100644 index 000000000..5b0812901 --- /dev/null +++ b/backend/src/test/java/moadong/unit/user/PasswordValidatorTest.java @@ -0,0 +1,40 @@ +package moadong.unit.user; + +import moadong.fixture.UserFixture; +import moadong.global.validator.PasswordValidator; +import moadong.util.annotations.UnitTest; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@UnitTest +public class PasswordValidatorTest { + private final PasswordValidator passwordValidator = new PasswordValidator(); + + @Test + void 유효한_비밀번호는_유효하다() { + boolean isValid = passwordValidator.isValid(UserFixture.collectPassword, null); + Assertions.assertThat(isValid).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { + "short1!", // 7자 (길이 부족) + "longpassword1234567890!", // 21자 (길이 초과) + "abcdefgh", // 영문 소문자만 (숫자 없음) + "ABCDEFGH", // 영문 대문자만 (숫자 없음) + "12345678", // 숫자만 (영문 없음) + "Abcdefgh", // 영문만 (숫자 없음) + "abcd1234*", // 허용되지 않은 특수문자 `*` + "abc def123!", // 공백 포함 + "passWord!", // 특수문자 있음, 숫자 없음 + "1234!@#$", // 숫자 + 특수문자, 문자 없음 + "Abcdef12()", // 괄호 포함 (허용되지 않은 특수문자) + "ABCD1234~", // `~` 특수문자 허용 안됨 + }) + void 유효하지_않은_비밀번호는_실패한다(String password) { + boolean isValid = passwordValidator.isValid(password, null); + Assertions.assertThat(isValid).isFalse(); + } +} diff --git a/backend/src/test/java/moadong/unit/user/UserLoginTest.java b/backend/src/test/java/moadong/unit/user/UserLoginTest.java new file mode 100644 index 000000000..cf943ce59 --- /dev/null +++ b/backend/src/test/java/moadong/unit/user/UserLoginTest.java @@ -0,0 +1,113 @@ +package moadong.unit.user; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.global.exception.RestApiException; +import moadong.global.util.JwtProvider; +import moadong.user.entity.RefreshToken; +import moadong.user.entity.User; +import moadong.user.payload.CustomUserDetails; +import moadong.user.payload.request.UserLoginRequest; +import moadong.user.payload.response.LoginResponse; +import moadong.user.repository.UserRepository; +import moadong.user.service.UserCommandService; +import moadong.user.util.CookieMaker; +import moadong.util.annotations.UnitTest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.http.ResponseCookie; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; +import java.util.Date; +import java.util.Optional; + +import static com.mongodb.assertions.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@UnitTest +public class UserLoginTest { + private static Validator validator; + @Spy + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + @Mock + private UserRepository userRepository; + @Mock + private ClubRepository clubRepository; + @Mock + private AuthenticationManager authenticationManager; + @Mock + private JwtProvider jwtProvider; + @Mock + private CookieMaker cookieMaker; + @InjectMocks + private UserCommandService userCommandService; + private MockHttpServletResponse realHttpServletResponse = new MockHttpServletResponse(); + + @BeforeAll + public static void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 정상적으로_로그인_시도를_할_경우_작동한다(){ + // Given + UserLoginRequest request = new UserLoginRequest("testUser", "password"); + String mockClubId = "clubId"; + Club mockClub = mock(Club.class); + User mockUser = new User(); + CustomUserDetails userDetails = new CustomUserDetails(mockUser); + long refreshTokenExpiredTime = 1123L; + + // When + when(authenticationManager.authenticate(any())) + .thenReturn(new UsernamePasswordAuthenticationToken(userDetails, null)); + when(clubRepository.findClubByUserId(any())).thenReturn(Optional.of(mockClub)); + when(mockClub.getId()).thenReturn(mockClubId); + when(userRepository.findUserByUserId(any())).thenReturn(Optional.of(mockUser)); + when(jwtProvider.generateAccessToken(any())).thenReturn("accessToken123"); + when(jwtProvider.generateRefreshToken(any())) + .thenReturn(new RefreshToken("refreshToken123", Date.from(Instant.now().plusSeconds(refreshTokenExpiredTime)))); + when(cookieMaker.makeRefreshTokenCookie(any())).thenReturn(ResponseCookie.from("refreshToken", "refreshToken123").build()); + + + LoginResponse result = userCommandService.loginUser(request, realHttpServletResponse); + + // Then + assertEquals("accessToken123", result.accessToken()); + assertEquals(mockClubId, result.clubId()); + + verify(userRepository).save(any(User.class)); + assertTrue(realHttpServletResponse.getHeader("Set-Cookie").contains("refreshToken=refreshToken123")); + } + + @Test + void 없는_아이디와_비밀번호를_입력_시에_실패한다(){ + UserLoginRequest request = new UserLoginRequest("testUser", "password"); + User mockUser = new User(); + CustomUserDetails userDetails = new CustomUserDetails(mockUser); + + // When + when(authenticationManager.authenticate(any())) + .thenReturn(new UsernamePasswordAuthenticationToken(userDetails, null)); + when(clubRepository.findClubByUserId(any())).thenReturn(Optional.empty()); + + assertThrows(RestApiException.class, ()->{ + LoginResponse result = userCommandService.loginUser(request, realHttpServletResponse); + }); + } +} diff --git a/backend/src/test/java/moadong/unit/user/UserRegisterTest.java b/backend/src/test/java/moadong/unit/user/UserRegisterTest.java new file mode 100644 index 000000000..f67e17256 --- /dev/null +++ b/backend/src/test/java/moadong/unit/user/UserRegisterTest.java @@ -0,0 +1,174 @@ +package moadong.unit.user; + + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import moadong.club.entity.Club; +import moadong.club.repository.ClubRepository; +import moadong.fixture.UserFixture; +import moadong.fixture.UserRequestFixture; +import moadong.global.exception.RestApiException; +import moadong.global.util.JwtProvider; +import moadong.user.entity.User; +import moadong.user.payload.request.UserRegisterRequest; +import moadong.user.repository.UserRepository; +import moadong.user.service.UserCommandService; +import moadong.user.util.CookieMaker; +import moadong.util.annotations.UnitTest; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.Mockito.*; + +@UnitTest +class UserRegisterTest { + private static Validator validator; + @Spy + private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + @Mock + private UserRepository userRepository; + @Mock + private ClubRepository clubRepository; + @Mock + private AuthenticationManager authenticationManager; + @Mock + private JwtProvider jwtProvider; + @Mock + private CookieMaker cookieMaker; + @InjectMocks + private UserCommandService userCommandService; + + @BeforeAll + public static void setUp() { + ValidatorFactory factory = Validation.buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + @Test + void 회원가입시_올바른_정보를_입력하면_성공한다() { + // given + String userId = UserFixture.collectUserId; + String password = UserFixture.collectPassword; + String name = UserFixture.collectName; + String phoneNumber = UserFixture.collectPhoneNumber; + + doAnswer(invocation -> invocation.getArgument(0)) + .when(userRepository).save(any(User.class)); + doReturn(new Club(userId)).when(clubRepository).save(any(Club.class)); + + UserRegisterRequest request = UserRequestFixture.createUserRegisterRequest(userId, password, name, phoneNumber); + + // when + User userResponse = userCommandService.registerUser(request); + + // then + assertThat(userResponse).isNotNull(); + assertThat(userResponse.getUserId()).isEqualTo(userId); + assertThat(userResponse.getUserInformation().getName()).isEqualTo(name); + assertThat(userResponse.getUserInformation().getPhoneNumber()).isEqualTo(phoneNumber); + assertThat(passwordEncoder.matches(password, userResponse.getPassword())).isTrue(); + + verify(userRepository, times(1)).save(any(User.class)); + verify(clubRepository, times(1)).save(any(Club.class)); + } + + /* + 아이디 규칙 + • 5자 ~ 20자 + • 적어도 하나의 소문자, 하나의 숫자가 포함 + • 소문자, 대문자, 숫자, 특수문자(!@#$~) 만 사용 + */ + @ParameterizedTest + @ValueSource(strings = { + "", // 빈 문자열 + " ", // 공백만 + "abcde", // 숫자 없음 + "ABCDE", // 숫자, 소문자 없음 + "12345", // 소문자 없음 + "ab!@", // 5자 미만 + 숫자 없음 + "abc123%^", // 허용되지 않은 특수문자 (`^`) + "abc1234567890123456789", // 21자 (초과) + "ab12", // 4자 (부족) + "ABC123", // 소문자 없음 + "abcd@", // 숫자 없음 + "1234@", // 소문자 없음 + "abc 123", // 공백 포함 + "abC!@#", // 숫자 없음 + "abc123*", // `*`은 허용되지 않음 + "a1@", // 길이 부족 + UserFixture.collectPassword // 비밀번호와 동일 (검증에 따라 실패할 수도 있음) + }) + void 회원가입시_유저_아이디가_조건에_맞지_않으면_실패한다(String userId) { + // given + String password = UserFixture.collectPassword; + String name = UserFixture.collectName; + String phoneNumber = UserFixture.collectPhoneNumber; + // when + try { + UserRegisterRequest request = UserRequestFixture.createUserRegisterRequest(userId, password, name, phoneNumber); + Set> violations = validator.validate(request); + // then + if (violations.isEmpty()) { + fail("예외나 검증 실패가 발생하지 않았습니다. 유효하지 않은 userId: " + userId); + } + } catch (RestApiException e) { + + } + } + + /* + 비밀번호 규칙 + • 8자 ~ 20자 + • 숫자랑 영어 대소문자 반드시 하나이상 포함 + • 특수문자는 반드시 하나가 필요하고 !@#$%^ 만 허용 + • 공백 포함 불가 + • 아이디와 동일한 비밀번호 불가 + */ + @ParameterizedTest + @ValueSource(strings = { + "short1!", // 7자 (길이 부족) + "longpassword1234567890!", // 21자 (길이 초과) + "abcdefgh", // 영문 소문자만 (숫자 없음) + "ABCDEFGH", // 영문 대문자만 (숫자 없음) + "12345678", // 숫자만 (영문 없음) + "Abcdefgh", // 영문만 (숫자 없음) + "abcd1234*", // 허용되지 않은 특수문자 `*` + "abc def123!", // 공백 포함 + UserFixture.collectUserId, // 아이디와 동일하거나 포함 + "passWord!", // 특수문자 있음, 숫자 없음 + "1234!@#$", // 숫자 + 특수문자, 문자 없음 + "Abcdef12()", // 괄호 포함 (허용되지 않은 특수문자) + "ABCD1234~", // `~` 특수문자 허용 안됨 + }) + void 회원가입시_유저_비밀번호가_조건에_맞지_않으면_실패한다(String password) { + String userId = UserFixture.collectUserId; + String name = UserFixture.collectName; + String phoneNumber = UserFixture.collectPhoneNumber; + + // when + try { + UserRegisterRequest request = UserRequestFixture.createUserRegisterRequest(userId, password, name, phoneNumber); + Set> violations = validator.validate(request); + // then + if (violations.isEmpty()) { + fail("예외나 검증 실패가 발생하지 않았습니다. 유효하지 않은 userId: " + userId); + } + } catch (RestApiException e) { + + } + } +} \ No newline at end of file diff --git a/backend/src/test/java/moadong/util/annotations/IntegrationTest.java b/backend/src/test/java/moadong/util/annotations/IntegrationTest.java new file mode 100644 index 000000000..304f03ecf --- /dev/null +++ b/backend/src/test/java/moadong/util/annotations/IntegrationTest.java @@ -0,0 +1,17 @@ +package moadong.util.annotations; + +import org.junit.jupiter.api.Tag; +import org.springframework.boot.test.context.SpringBootTest; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest +@Tag(TestTypeConstants.INTEGRATION_TEST) +public @interface IntegrationTest { + +} diff --git a/backend/src/test/java/moadong/util/annotations/TestTypeConstants.java b/backend/src/test/java/moadong/util/annotations/TestTypeConstants.java new file mode 100644 index 000000000..5410e6c93 --- /dev/null +++ b/backend/src/test/java/moadong/util/annotations/TestTypeConstants.java @@ -0,0 +1,10 @@ +package moadong.util.annotations; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TestTypeConstants { + public static final String UNIT_TEST = "UnitTest"; + public static final String INTEGRATION_TEST = "IntegrationTest"; +} diff --git a/backend/src/test/java/moadong/util/annotations/UnitTest.java b/backend/src/test/java/moadong/util/annotations/UnitTest.java new file mode 100644 index 000000000..86a5f6517 --- /dev/null +++ b/backend/src/test/java/moadong/util/annotations/UnitTest.java @@ -0,0 +1,20 @@ +package moadong.util.annotations; + + + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Tag(TestTypeConstants.UNIT_TEST) +@ExtendWith(MockitoExtension.class) +public @interface UnitTest { + +}