diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b2ba913c..7a3738b58 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,23 +1,23 @@ -## PR ๋‚ด์šฉ +# ๐Ÿ“Œ PR ๋‚ด์šฉ
-## PR ์„ธ๋ถ€์‚ฌํ•ญ +## ๐Ÿ” PR ์„ธ๋ถ€์‚ฌํ•ญ
-## ๊ด€๋ จ ์Šคํฌ๋ฆฐ์ƒท +## ๐Ÿ“ธ ๊ด€๋ จ ์Šคํฌ๋ฆฐ์ƒท
-## ์ฃผ์˜์‚ฌํ•ญ +## ๐Ÿ“ ์ฃผ์˜์‚ฌํ•ญ
-## ์ฒดํฌ ๋ฆฌ์ŠคํŠธ +## โœ… ์ฒดํฌ๋ฆฌ์ŠคํŠธ - [ ] ๋ฆฌ๋ทฐ์–ด ์„ค์ • - [ ] Assignee ์„ค์ • - [ ] Label ์„ค์ • -- [ ] ์ œ๋ชฉ ์–‘์‹ ๋งž์ท„๋‚˜์š”? (ex. #0 Feat: ๊ธฐ๋Šฅ ์ถ”๊ฐ€) -- [ ] ๋ณ€๊ฒฝ ์‚ฌํ•ญ์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ \ No newline at end of file +- [ ] ์ œ๋ชฉ ์–‘์‹ ๋งž์ท„๋‚˜์š”? (ex. [WTH-01] PR ํ…œํ”Œ๋ฆฟ ์ˆ˜์ •) +- [ ] ๋ณ€๊ฒฝ ์‚ฌํ•ญ์— ๋Œ€ํ•œ ํ…Œ์ŠคํŠธ diff --git a/Dockerfile-dev b/Dockerfile-dev index a9b6b7826..e46cdb889 100644 --- a/Dockerfile-dev +++ b/Dockerfile-dev @@ -1,5 +1,5 @@ -# open jdk 17 ๋ฒ„์ „์˜ alpine ๋ฆฌ๋ˆ…์Šค ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑ -FROM openjdk:17-alpine +# eclipse-temurin 17 ๋ฒ„์ „์˜ alpine ๋ฆฌ๋ˆ…์Šค ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑ +FROM eclipse-temurin:17-jdk-alpine # build๊ฐ€ ๋˜๋Š” ์‹œ์ ์— JAR_FILE์ด๋ผ๋Š” ๋ณ€์ˆ˜ ๋ช…์— build/libs/*.jar ์„ ์–ธ # build/libs - gradle๋กœ ๋นŒ๋“œํ–ˆ์„ ๋•Œ jar ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒฝ๋กœ diff --git a/Dockerfile-prod b/Dockerfile-prod index a20969c72..92b747a24 100644 --- a/Dockerfile-prod +++ b/Dockerfile-prod @@ -1,5 +1,5 @@ -# open jdk 17 ๋ฒ„์ „์˜ alpine ๋ฆฌ๋ˆ…์Šค ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑ -FROM openjdk:17-alpine +# eclipse-temurin 17 ๋ฒ„์ „์˜ alpine ๋ฆฌ๋ˆ…์Šค ํ™˜๊ฒฝ์„ ๊ตฌ์„ฑ +FROM eclipse-temurin:17-jdk-alpine # build๊ฐ€ ๋˜๋Š” ์‹œ์ ์— JAR_FILE์ด๋ผ๋Š” ๋ณ€์ˆ˜ ๋ช…์— build/libs/*.jar ์„ ์–ธ # build/libs - gradle๋กœ ๋นŒ๋“œํ–ˆ์„ ๋•Œ jar ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜๋Š” ๊ฒฝ๋กœ diff --git a/build.gradle b/build.gradle index dd370c44d..33b69c6b9 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.1' + id 'org.springframework.boot' version '3.5.7' id 'io.spring.dependency-management' version '1.1.5' } @@ -52,10 +52,15 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation "org.junit.jupiter:junit-jupiter:5.8.1" + testImplementation "org.testcontainers:testcontainers:2.0.1" + testImplementation "org.testcontainers:testcontainers-junit-jupiter:2.0.1" + testImplementation "org.testcontainers:testcontainers-mysql:2.0.1" + testImplementation 'org.springframework.boot:spring-boot-testcontainers' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' // AWS Spring Cloud implementation 'software.amazon.awssdk:s3:2.19.1' @@ -66,6 +71,10 @@ dependencies { // Spring Authorization Server implementation 'org.springframework.boot:spring-boot-starter-oauth2-authorization-server' + // Prometheus + implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + } tasks.named('test') { diff --git a/src/main/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java b/src/main/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java index 955172a1c..9840ceac5 100644 --- a/src/main/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java +++ b/src/main/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImpl.java @@ -44,7 +44,7 @@ public void checkIn(Long userId, Integer code) throws AttendanceCodeMismatchExce LocalDateTime now = LocalDateTime.now(); Attendance todayMeeting = user.getAttendances().stream() - .filter(attendance -> attendance.getMeeting().getStart().isBefore(now) + .filter(attendance -> attendance.getMeeting().getStart().minusMinutes(10).isBefore(now) && attendance.getMeeting().getEnd().isAfter(now)) .findAny() .orElseThrow(AttendanceNotFoundException::new); diff --git a/src/main/java/leets/weeth/domain/board/application/dto/NoticeDTO.java b/src/main/java/leets/weeth/domain/board/application/dto/NoticeDTO.java index 507b50174..1b56c004e 100644 --- a/src/main/java/leets/weeth/domain/board/application/dto/NoticeDTO.java +++ b/src/main/java/leets/weeth/domain/board/application/dto/NoticeDTO.java @@ -1,5 +1,6 @@ package leets.weeth.domain.board.application.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import leets.weeth.domain.comment.application.dto.CommentDTO; @@ -59,4 +60,11 @@ public record ResponseAll( ) { } + @Builder + public record SaveResponse( + @Schema(description = "๊ณต์ง€์‚ฌํ•ญ ์ƒ์„ฑ ์‘๋‹ต", example = "1") + long id + ) { + } + } diff --git a/src/main/java/leets/weeth/domain/board/application/dto/PostDTO.java b/src/main/java/leets/weeth/domain/board/application/dto/PostDTO.java index ced6270ee..bcdc38fb4 100644 --- a/src/main/java/leets/weeth/domain/board/application/dto/PostDTO.java +++ b/src/main/java/leets/weeth/domain/board/application/dto/PostDTO.java @@ -1,9 +1,9 @@ package leets.weeth.domain.board.application.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import java.time.LocalDateTime; -import java.util.List; import leets.weeth.domain.board.domain.entity.enums.Category; import leets.weeth.domain.board.domain.entity.enums.Part; import leets.weeth.domain.comment.application.dto.CommentDTO; @@ -13,19 +13,23 @@ import leets.weeth.domain.user.domain.entity.enums.Role; import lombok.Builder; +import java.time.LocalDateTime; +import java.util.List; + public class PostDTO { @Builder public record Save( - @NotNull String title, - @NotNull String content, + @NotBlank(message = "์ œ๋ชฉ ์ž…๋ ฅ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") String title, + @NotBlank(message = "๋‚ด์šฉ ์ž…๋ ฅ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") String content, @NotNull Category category, String studyName, int week, @NotNull Part part, @NotNull Integer cardinalNumber, @Valid List<@NotNull FileSaveRequest> files - ){} + ) { + } @Builder public record SaveEducation( @@ -34,7 +38,15 @@ public record SaveEducation( @NotNull List parts, @NotNull Integer cardinalNumber, @Valid List<@NotNull FileSaveRequest> files - ){} + ) { + } + + @Builder + public record SaveResponse( + @Schema(description = "๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ์‹œ ์‘๋‹ต", example = "1") + long id + ) { + } @Builder public record Update( @@ -45,7 +57,8 @@ public record Update( Part part, Integer cardinalNumber, @Valid List files - ){} + ) { + } @Builder public record UpdateEducation( @@ -54,7 +67,8 @@ public record UpdateEducation( List parts, Integer cardinalNumber, @Valid List files - ){} + ) { + } @Builder public record Response( @@ -73,7 +87,8 @@ public record Response( Integer commentCount, List comments, List fileUrls - ){} + ) { + } @Builder public record ResponseAll( @@ -90,7 +105,8 @@ public record ResponseAll( Integer commentCount, boolean hasFile, boolean isNew - ){} + ) { + } @Builder public record ResponseEducationAll( @@ -105,9 +121,11 @@ public record ResponseEducationAll( Integer commentCount, boolean hasFile, boolean isNew - ){} + ) { + } public record ResponseStudyNames( List studyNames - ) {} + ) { + } } diff --git a/src/main/java/leets/weeth/domain/board/application/mapper/NoticeMapper.java b/src/main/java/leets/weeth/domain/board/application/mapper/NoticeMapper.java index fd39cffbd..9509e24ae 100644 --- a/src/main/java/leets/weeth/domain/board/application/mapper/NoticeMapper.java +++ b/src/main/java/leets/weeth/domain/board/application/mapper/NoticeMapper.java @@ -37,4 +37,6 @@ public interface NoticeMapper { }) NoticeDTO.Response toNoticeDto(Notice notice, List fileUrls, List comments); + NoticeDTO.SaveResponse toSaveResponse(Notice notice); + } diff --git a/src/main/java/leets/weeth/domain/board/application/mapper/PostMapper.java b/src/main/java/leets/weeth/domain/board/application/mapper/PostMapper.java index 95b199095..1bbade147 100644 --- a/src/main/java/leets/weeth/domain/board/application/mapper/PostMapper.java +++ b/src/main/java/leets/weeth/domain/board/application/mapper/PostMapper.java @@ -34,6 +34,8 @@ public interface PostMapper { @Mapping(target = "category", constant = "Education") Post fromEducationDto(PostDTO.SaveEducation dto, User user); + PostDTO.SaveResponse toSaveResponse(Post post); + @Mappings({ @Mapping(target = "name", source = "post.user.name"), @Mapping(target = "position", source = "post.user.position"), diff --git a/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecase.java b/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecase.java index 00f20557d..708e20615 100644 --- a/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecase.java +++ b/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecase.java @@ -6,14 +6,13 @@ public interface NoticeUsecase { - - void save(NoticeDTO.Save dto, Long userId); + NoticeDTO.SaveResponse save(NoticeDTO.Save dto, Long userId); NoticeDTO.Response findNotice(Long noticeId); Slice findNotices(int pageNumber, int pageSize); - void update(Long noticeId, NoticeDTO.Update dto, Long userId) throws UserNotMatchException; + NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) throws UserNotMatchException; void delete(Long noticeId, Long userId) throws UserNotMatchException; diff --git a/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java b/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java index a04c3bc83..bcbe76acb 100644 --- a/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java +++ b/src/main/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImpl.java @@ -1,9 +1,5 @@ package leets.weeth.domain.board.application.usecase; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import leets.weeth.domain.board.application.dto.NoticeDTO; import leets.weeth.domain.board.application.exception.NoSearchResultException; import leets.weeth.domain.board.application.exception.PageNotFoundException; @@ -33,6 +29,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor public class NoticeUsecaseImpl implements NoticeUsecase { @@ -54,14 +55,16 @@ public class NoticeUsecaseImpl implements NoticeUsecase { @Override @Transactional - public void save(NoticeDTO.Save request, Long userId) { + public NoticeDTO.SaveResponse save(NoticeDTO.Save request, Long userId) { User user = userGetService.find(userId); Notice notice = mapper.fromNoticeDto(request, user); - noticeSaveService.save(notice); + Notice savedNotice = noticeSaveService.save(notice); List files = fileMapper.toFileList(request.files(), notice); fileSaveService.save(files); + + return mapper.toSaveResponse(savedNotice); } @Override @@ -103,7 +106,7 @@ public Slice searchNotice(String keyword, int pageNumber, @Override @Transactional - public void update(Long noticeId, NoticeDTO.Update dto, Long userId) { + public NoticeDTO.SaveResponse update(Long noticeId, NoticeDTO.Update dto, Long userId) { Notice notice = validateOwner(noticeId, userId); List fileList = getFiles(noticeId); @@ -113,6 +116,8 @@ public void update(Long noticeId, NoticeDTO.Update dto, Long userId) { fileSaveService.save(files); noticeUpdateService.update(notice, dto); + + return mapper.toSaveResponse(notice); } @Override diff --git a/src/main/java/leets/weeth/domain/board/application/usecase/PostUseCaseImpl.java b/src/main/java/leets/weeth/domain/board/application/usecase/PostUseCaseImpl.java index 1ad0e60ea..b213258c6 100644 --- a/src/main/java/leets/weeth/domain/board/application/usecase/PostUseCaseImpl.java +++ b/src/main/java/leets/weeth/domain/board/application/usecase/PostUseCaseImpl.java @@ -1,9 +1,5 @@ package leets.weeth.domain.board.application.usecase; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import leets.weeth.domain.board.application.dto.PartPostDTO; import leets.weeth.domain.board.application.dto.PostDTO; import leets.weeth.domain.board.application.exception.CategoryAccessDeniedException; @@ -33,14 +29,15 @@ import leets.weeth.domain.user.domain.service.UserCardinalGetService; import leets.weeth.domain.user.domain.service.UserGetService; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Slice; -import org.springframework.data.domain.SliceImpl; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor public class PostUseCaseImpl implements PostUsecase { @@ -64,7 +61,7 @@ public class PostUseCaseImpl implements PostUsecase { @Override @Transactional - public void save(PostDTO.Save request, Long userId) { + public PostDTO.SaveResponse save(PostDTO.Save request, Long userId) { User user = userGetService.find(userId); if (request.category() == Category.Education @@ -74,23 +71,26 @@ public void save(PostDTO.Save request, Long userId) { cardinalGetService.findByUserSide(request.cardinalNumber()); Post post = mapper.fromPostDto(request, user); - postSaveService.save(post); + Post savedPost = postSaveService.save(post); List files = fileMapper.toFileList(request.files(), post); fileSaveService.save(files); + + return mapper.toSaveResponse(savedPost); } @Override @Transactional - public void saveEducation(PostDTO.SaveEducation request, Long userId) { + public PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId) { User user = userGetService.find(userId); Post post = mapper.fromEducationDto(request, user); - - postSaveService.save(post); + Post saverPost = postSaveService.save(post); List files = fileMapper.toFileList(request.files(), post); fileSaveService.save(files); + + return mapper.toSaveResponse(saverPost); } @Override @@ -193,7 +193,7 @@ public Slice searchEducation(String keyword, int p @Override @Transactional - public void update(Long postId, PostDTO.Update dto, Long userId) { + public PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) { Post post = validateOwner(postId, userId); if (dto.files() != null) { @@ -205,11 +205,13 @@ public void update(Long postId, PostDTO.Update dto, Long userId) { } postUpdateService.update(post, dto); + + return mapper.toSaveResponse(post); } @Override @Transactional - public void updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) { + public PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) { Post post = validateOwner(postId, userId); if (dto.files() != null) { @@ -221,6 +223,8 @@ public void updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userI } postUpdateService.updateEducation(post, dto); + + return mapper.toSaveResponse(post); } @Override diff --git a/src/main/java/leets/weeth/domain/board/application/usecase/PostUsecase.java b/src/main/java/leets/weeth/domain/board/application/usecase/PostUsecase.java index c2acb2e43..4755a26ca 100644 --- a/src/main/java/leets/weeth/domain/board/application/usecase/PostUsecase.java +++ b/src/main/java/leets/weeth/domain/board/application/usecase/PostUsecase.java @@ -9,9 +9,9 @@ public interface PostUsecase { - void save(PostDTO.Save request, Long userId); + PostDTO.SaveResponse save(PostDTO.Save request, Long userId); - void saveEducation(PostDTO.SaveEducation request, Long userId); + PostDTO.SaveResponse saveEducation(PostDTO.SaveEducation request, Long userId); PostDTO.Response findPost(Long postId); @@ -23,9 +23,9 @@ public interface PostUsecase { PostDTO.ResponseStudyNames findStudyNames(Part part); - void update(Long postId, PostDTO.Update dto, Long userId) throws UserNotMatchException; + PostDTO.SaveResponse update(Long postId, PostDTO.Update dto, Long userId) throws UserNotMatchException; - void updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) throws UserNotMatchException; + PostDTO.SaveResponse updateEducation(Long postId, PostDTO.UpdateEducation dto, Long userId) throws UserNotMatchException; void delete(Long postId, Long userId) throws UserNotMatchException; diff --git a/src/main/java/leets/weeth/domain/board/domain/service/NoticeSaveService.java b/src/main/java/leets/weeth/domain/board/domain/service/NoticeSaveService.java index b26e1061b..30dbcfbfa 100644 --- a/src/main/java/leets/weeth/domain/board/domain/service/NoticeSaveService.java +++ b/src/main/java/leets/weeth/domain/board/domain/service/NoticeSaveService.java @@ -1,6 +1,5 @@ package leets.weeth.domain.board.domain.service; -import jakarta.transaction.Transactional; import leets.weeth.domain.board.domain.entity.Notice; import leets.weeth.domain.board.domain.repository.NoticeRepository; import lombok.RequiredArgsConstructor; @@ -12,8 +11,8 @@ public class NoticeSaveService { private final NoticeRepository noticeRepository; - public void save(Notice notice){ - noticeRepository.save(notice); + public Notice save(Notice notice){ + return noticeRepository.save(notice); } } diff --git a/src/main/java/leets/weeth/domain/board/domain/service/PostSaveService.java b/src/main/java/leets/weeth/domain/board/domain/service/PostSaveService.java index 80cd5b555..c427b3c3f 100644 --- a/src/main/java/leets/weeth/domain/board/domain/service/PostSaveService.java +++ b/src/main/java/leets/weeth/domain/board/domain/service/PostSaveService.java @@ -1,6 +1,5 @@ package leets.weeth.domain.board.domain.service; -import jakarta.transaction.Transactional; import leets.weeth.domain.board.domain.entity.Post; import leets.weeth.domain.board.domain.repository.PostRepository; import lombok.RequiredArgsConstructor; @@ -12,8 +11,7 @@ public class PostSaveService { private final PostRepository postRepository; - public void save(Post post) { - postRepository.save(post); + public Post save(Post post) { + return postRepository.save(post); } - } diff --git a/src/main/java/leets/weeth/domain/board/presentation/EducationAdminController.java b/src/main/java/leets/weeth/domain/board/presentation/EducationAdminController.java index 106eaa6c1..196bc8cf2 100644 --- a/src/main/java/leets/weeth/domain/board/presentation/EducationAdminController.java +++ b/src/main/java/leets/weeth/domain/board/presentation/EducationAdminController.java @@ -1,8 +1,5 @@ package leets.weeth.domain.board.presentation; -import static leets.weeth.domain.board.presentation.ResponseMessage.EDUCATION_UPDATED_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_CREATED_SUCCESS; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -13,12 +10,10 @@ import leets.weeth.global.auth.annotation.CurrentUser; import leets.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import static leets.weeth.domain.board.presentation.ResponseMessage.EDUCATION_UPDATED_SUCCESS; +import static leets.weeth.domain.board.presentation.ResponseMessage.POST_CREATED_SUCCESS; @Tag(name = "EDUCATION ADMIN", description = "[ADMIN] ๊ณต์ง€์‚ฌํ•ญ ๊ต์œก์ž๋ฃŒ API") @RestController @@ -29,18 +24,19 @@ public class EducationAdminController { @PostMapping("/education") @Operation(summary = "๊ต์œก์ž๋ฃŒ ์ƒ์„ฑ") - public CommonResponse saveEducation(@RequestBody @Valid PostDTO.SaveEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) { - postUsecase.saveEducation(dto, userId); + public CommonResponse saveEducation(@RequestBody @Valid PostDTO.SaveEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) { + PostDTO.SaveResponse response = postUsecase.saveEducation(dto, userId); - return CommonResponse.createSuccess(POST_CREATED_SUCCESS.getMessage()); + return CommonResponse.createSuccess(POST_CREATED_SUCCESS.getMessage(), response); } @PatchMapping(value = "/{boardId}") @Operation(summary="๊ต์œก์ž๋ฃŒ ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •") - public CommonResponse update(@PathVariable Long boardId, + public CommonResponse update(@PathVariable Long boardId, @RequestBody @Valid PostDTO.UpdateEducation dto, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postUsecase.updateEducation(boardId, dto, userId); - return CommonResponse.createSuccess(EDUCATION_UPDATED_SUCCESS.getMessage()); + PostDTO.SaveResponse response = postUsecase.updateEducation(boardId, dto, userId); + + return CommonResponse.createSuccess(EDUCATION_UPDATED_SUCCESS.getMessage(), response); } } diff --git a/src/main/java/leets/weeth/domain/board/presentation/NoticeAdminController.java b/src/main/java/leets/weeth/domain/board/presentation/NoticeAdminController.java index 28f4bcc95..520736d60 100644 --- a/src/main/java/leets/weeth/domain/board/presentation/NoticeAdminController.java +++ b/src/main/java/leets/weeth/domain/board/presentation/NoticeAdminController.java @@ -6,15 +6,11 @@ import jakarta.validation.Valid; import leets.weeth.domain.board.application.dto.NoticeDTO; import leets.weeth.domain.board.application.usecase.NoticeUsecase; -import leets.weeth.global.auth.annotation.CurrentUser; import leets.weeth.domain.user.application.exception.UserNotMatchException; +import leets.weeth.global.auth.annotation.CurrentUser; import leets.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; -import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -import java.util.List; import static leets.weeth.domain.board.presentation.ResponseMessage.*; @@ -28,19 +24,21 @@ public class NoticeAdminController { @PostMapping @Operation(summary="๊ณต์ง€์‚ฌํ•ญ ์ƒ์„ฑ") - public CommonResponse save(@RequestBody @Valid NoticeDTO.Save dto, + public CommonResponse save(@RequestBody @Valid NoticeDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { - noticeUsecase.save(dto, userId); - return CommonResponse.createSuccess(NOTICE_CREATED_SUCCESS.getMessage()); + NoticeDTO.SaveResponse response = noticeUsecase.save(dto, userId); + + return CommonResponse.createSuccess(NOTICE_CREATED_SUCCESS.getMessage(), response); } @PatchMapping(value = "/{noticeId}") @Operation(summary="ํŠน์ • ๊ณต์ง€์‚ฌํ•ญ ์ˆ˜์ •") - public CommonResponse update(@PathVariable Long noticeId, + public CommonResponse update(@PathVariable Long noticeId, @RequestBody @Valid NoticeDTO.Update dto, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - noticeUsecase.update(noticeId, dto, userId); - return CommonResponse.createSuccess(NOTICE_UPDATED_SUCCESS.getMessage()); + NoticeDTO.SaveResponse response = noticeUsecase.update(noticeId, dto, userId); + + return CommonResponse.createSuccess(NOTICE_UPDATED_SUCCESS.getMessage(), response); } @DeleteMapping("/{noticeId}") diff --git a/src/main/java/leets/weeth/domain/board/presentation/PostController.java b/src/main/java/leets/weeth/domain/board/presentation/PostController.java index 05714ed63..6bfbf45be 100644 --- a/src/main/java/leets/weeth/domain/board/presentation/PostController.java +++ b/src/main/java/leets/weeth/domain/board/presentation/PostController.java @@ -1,15 +1,5 @@ package leets.weeth.domain.board.presentation; -import static leets.weeth.domain.board.presentation.ResponseMessage.EDUCATION_SEARCH_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_CREATED_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_DELETED_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_EDU_FIND_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_FIND_ALL_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_FIND_BY_ID_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_PART_FIND_ALL_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_SEARCH_SUCCESS; -import static leets.weeth.domain.board.presentation.ResponseMessage.POST_UPDATED_SUCCESS; - import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,16 +13,9 @@ import leets.weeth.global.common.response.CommonResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Slice; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import static leets.weeth.domain.board.presentation.ResponseMessage.*; @Tag(name = "BOARD", description = "๊ฒŒ์‹œํŒ API") @RestController @@ -44,10 +27,10 @@ public class PostController { @PostMapping @Operation(summary="ํŒŒํŠธ ๊ฒŒ์‹œ๊ธ€ ์ƒ์„ฑ (์Šคํ„ฐ๋”” ๋กœ๊ทธ, ์•„ํ‹ฐํด)") - public CommonResponse save(@RequestBody @Valid PostDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { - postUsecase.save(dto, userId); + public CommonResponse save(@RequestBody @Valid PostDTO.Save dto, @Parameter(hidden = true) @CurrentUser Long userId) { + PostDTO.SaveResponse response = postUsecase.save(dto, userId); - return CommonResponse.createSuccess(POST_CREATED_SUCCESS.getMessage()); + return CommonResponse.createSuccess(POST_CREATED_SUCCESS.getMessage(), response); } @GetMapping @@ -101,11 +84,12 @@ public CommonResponse> findEducation(@Reques @PatchMapping(value = "/{boardId}/part") @Operation(summary="ํŒŒํŠธ ๊ฒŒ์‹œ๊ธ€ ์ˆ˜์ •") - public CommonResponse update(@PathVariable Long boardId, + public CommonResponse update(@PathVariable Long boardId, @RequestBody @Valid PostDTO.Update dto, @Parameter(hidden = true) @CurrentUser Long userId) throws UserNotMatchException { - postUsecase.update(boardId, dto, userId); - return CommonResponse.createSuccess(POST_UPDATED_SUCCESS.getMessage()); + PostDTO.SaveResponse response = postUsecase.update(boardId, dto, userId); + + return CommonResponse.createSuccess(POST_UPDATED_SUCCESS.getMessage(), response); } @DeleteMapping("/{boardId}") diff --git a/src/main/java/leets/weeth/domain/comment/application/dto/CommentDTO.java b/src/main/java/leets/weeth/domain/comment/application/dto/CommentDTO.java index 639e377d1..25d124c32 100644 --- a/src/main/java/leets/weeth/domain/comment/application/dto/CommentDTO.java +++ b/src/main/java/leets/weeth/domain/comment/application/dto/CommentDTO.java @@ -3,6 +3,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import leets.weeth.domain.file.application.dto.request.FileSaveRequest; import leets.weeth.domain.file.application.dto.response.FileResponse; import leets.weeth.domain.user.domain.entity.enums.Position; @@ -17,13 +18,13 @@ public class CommentDTO { @Builder public record Save( Long parentCommentId, - @NotBlank String content, + @NotBlank @Size(max=300, message = "๋Œ“๊ธ€์€ ์ตœ๋Œ€ 300์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") String content, @Valid List<@NotNull FileSaveRequest> files ){} @Builder public record Update( - @NotBlank String content, + @NotBlank @Size(max=300, message = "๋Œ“๊ธ€์€ ์ตœ๋Œ€ 300์ž๊นŒ์ง€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.") String content, @Valid List<@NotNull FileSaveRequest> files ){} diff --git a/src/main/java/leets/weeth/domain/comment/domain/entity/Comment.java b/src/main/java/leets/weeth/domain/comment/domain/entity/Comment.java index db791dbb2..dde416246 100644 --- a/src/main/java/leets/weeth/domain/comment/domain/entity/Comment.java +++ b/src/main/java/leets/weeth/domain/comment/domain/entity/Comment.java @@ -27,6 +27,7 @@ public class Comment extends BaseEntity { @Column(name = "comment_id") private Long id; + @Column(length = 300) private String content; @Column(nullable = false) diff --git a/src/main/java/leets/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java b/src/main/java/leets/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java index ab80aea31..8889a49b7 100644 --- a/src/main/java/leets/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java +++ b/src/main/java/leets/weeth/domain/schedule/application/usecase/MeetingUseCaseImpl.java @@ -1,5 +1,7 @@ package leets.weeth.domain.schedule.application.usecase; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import leets.weeth.domain.attendance.domain.entity.Attendance; import leets.weeth.domain.attendance.domain.service.AttendanceDeleteService; import leets.weeth.domain.attendance.domain.service.AttendanceGetService; @@ -44,6 +46,9 @@ public class MeetingUseCaseImpl implements MeetingUseCase { private final AttendanceUpdateService attendanceUpdateService; private final CardinalGetService cardinalGetService; + @PersistenceContext + private EntityManager em; + @Override public Response find(Long userId, Long meetingId) { User user = userGetService.find(userId); @@ -103,6 +108,9 @@ public void delete(Long meetingId) { attendanceUpdateService.updateUserAttendanceByStatus(attendances); + em.flush(); + em.clear(); + attendanceDeleteService.deleteAll(meeting); meetingDeleteService.delete(meeting); } diff --git a/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java b/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java index 2cfdb8cf5..30ac0336b 100644 --- a/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java +++ b/src/main/java/leets/weeth/domain/user/application/dto/request/UserRequestDto.java @@ -1,5 +1,6 @@ package leets.weeth.domain.user.application.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -27,7 +28,10 @@ public record SignUp( } public record Register( - @NotNull Long kakaoId, + @Schema(description = "kakao๋กœ ํšŒ์›๊ฐ€์ž… ํ•˜๋Š” ๊ฒฝ์šฐ") + Long kakaoId, + @Schema(description = "์• ํ”Œ๋กœ ํšŒ์›๊ฐ€์ž… ํ•˜๋Š” ๊ฒฝ์šฐ - Apple OAuth authCode") + String appleAuthCode, @NotBlank String name, @NotBlank String studentId, @NotBlank String email, diff --git a/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java b/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java index 8f38c0ed5..5a76d6456 100644 --- a/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java +++ b/src/main/java/leets/weeth/domain/user/application/dto/response/UserResponseDto.java @@ -13,6 +13,7 @@ public class UserResponseDto { public record SocialLoginResponse( Long id, Long kakaoId, + String appleIdToken, LoginStatus status, String accessToken, String refreshToken diff --git a/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java b/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java index e41866ecd..046a0f5f2 100644 --- a/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java +++ b/src/main/java/leets/weeth/domain/user/application/mapper/UserMapper.java @@ -49,11 +49,15 @@ public interface UserMapper { @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), @Mapping(target = "id", source = "user.id"), @Mapping(target = "kakaoId", source = "user.kakaoId"), + @Mapping(target = "appleIdToken", expression = "java(null)") }) SocialLoginResponse toLoginResponse(User user, JwtDto dto); @Mappings({ @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), + @Mapping(target = "appleIdToken", expression = "java(null)"), + @Mapping(target = "accessToken", expression = "java(null)"), + @Mapping(target = "refreshToken", expression = "java(null)") }) SocialLoginResponse toIntegrateResponse(Long kakaoId); @@ -66,6 +70,24 @@ public interface UserMapper { @Mapping(target = "cardinals", expression = "java( toCardinalNumbers(userCardinals) )") UserResponseDto.UserInfo toUserInfoDto(User user, List userCardinals); + @Mappings({ + @Mapping(target = "status", expression = "java(LoginStatus.LOGIN)"), + @Mapping(target = "id", source = "user.id"), + @Mapping(target = "appleIdToken", expression = "java(null)"), + @Mapping(target = "kakaoId", expression = "java(null)") + }) + SocialLoginResponse toAppleLoginResponse(User user, JwtDto dto); + + @Mappings({ + @Mapping(target = "status", expression = "java(LoginStatus.INTEGRATE)"), + @Mapping(target = "id", expression = "java(null)"), + @Mapping(target = "appleIdToken", source = "appleIdToken"), + @Mapping(target = "kakaoId", expression = "java(null)"), + @Mapping(target = "accessToken", expression = "java(null)"), + @Mapping(target = "refreshToken", expression = "java(null)") + }) + SocialLoginResponse toAppleIntegrateResponse(String appleIdToken); + default String toString(Department department) { return department.getValue(); } diff --git a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java index ae71bf995..e1164291f 100644 --- a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java +++ b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCase.java @@ -37,4 +37,8 @@ public interface UserUseCase { List searchUser(String keyword); + SocialLoginResponse appleLogin(Login dto); + + void appleRegister(Register dto); + } diff --git a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java index 2cbe3ecdf..5a4ddab73 100644 --- a/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java +++ b/src/main/java/leets/weeth/domain/user/application/usecase/UserUseCaseImpl.java @@ -11,6 +11,8 @@ import leets.weeth.domain.user.domain.entity.User; import leets.weeth.domain.user.domain.entity.UserCardinal; import leets.weeth.domain.user.domain.service.*; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; import leets.weeth.global.auth.jwt.application.dto.JwtDto; import leets.weeth.global.auth.jwt.application.usecase.JwtManageUseCase; import leets.weeth.global.auth.kakao.KakaoAuthService; @@ -18,6 +20,7 @@ import leets.weeth.global.auth.kakao.dto.KakaoUserInfoResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -25,10 +28,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.stream.Collectors; import static leets.weeth.domain.user.application.dto.request.UserRequestDto.*; @@ -44,6 +44,7 @@ public class UserUseCaseImpl implements UserUseCase { private final UserGetService userGetService; private final UserUpdateService userUpdateService; private final KakaoAuthService kakaoAuthService; + private final leets.weeth.global.auth.apple.AppleAuthService appleAuthService; private final CardinalGetService cardinalGetService; private final UserCardinalSaveService userCardinalSaveService; private final UserCardinalGetService userCardinalGetService; @@ -51,6 +52,7 @@ public class UserUseCaseImpl implements UserUseCase { private final UserMapper mapper; private final CardinalMapper cardinalMapper; private final PasswordEncoder passwordEncoder; + private final Environment environment; @Override @Transactional(readOnly = true) @@ -238,4 +240,75 @@ private UserCardinalDto getUserCardinalDto(Long userId) { return cardinalMapper.toUserCardinalDto(user, userCardinals); } + + @Override + @Transactional(readOnly = true) + public SocialLoginResponse appleLogin(Login dto) { + // Apple Token ์š”์ฒญ ๋ฐ ์œ ์ € ์ •๋ณด ์š”์ฒญ + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.authCode()); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + + String appleIdToken = tokenResponse.id_token(); + String appleId = userInfo.appleId(); + + Optional optionalUser = userGetService.findByAppleId(appleId); + + //todo: ์ถ”ํ›„ ์• ํ”Œ ๋กœ๊ทธ์ธ ์—ฐ๋™์„ ์œ„ํ•ด appleIdToken์„ ๋ฐ˜ํ™˜ + // ์• ํ”Œ ๋กœ๊ทธ์ธ ์—ฐ๋™ API ์š”์ฒญ์‹œ appleIdToken์„ ํ•จ๊ป˜ ๋„ฃ์–ด์ฃผ๋ฉด ๊ทธ๋•Œ ๋””์ฝ”๋”ฉํ•ด์„œ appleId๋ฅผ ์ถ”์ถœ + if (optionalUser.isEmpty()) { + return mapper.toAppleIntegrateResponse(appleIdToken); + } + + User user = optionalUser.get(); + if (user.isInactive()) { + throw new UserInActiveException(); + } + + JwtDto token = jwtManageUseCase.create(user.getId(), user.getEmail(), user.getRole()); + return mapper.toAppleLoginResponse(user, token); + } + + @Override + @Transactional + public void appleRegister(Register dto) { + validate(dto); + + // Apple authCode๋กœ ํ† ํฐ ๊ตํ™˜ ํ›„ ID Token ๊ฒ€์ฆ ๋ฐ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(dto.appleAuthCode()); + AppleUserInfo appleUserInfo = appleAuthService.verifyAndDecodeIdToken(tokenResponse.id_token()); + + Cardinal cardinal = cardinalGetService.findByUserSide(dto.cardinal()); + + User user = mapper.from(dto); + // Apple ID ์„ค์ • + user.addAppleId(appleUserInfo.appleId()); + + UserCardinal userCardinal = new UserCardinal(user, cardinal); + + userSaveService.save(user); + userCardinalSaveService.save(userCardinal); + + // dev ํ™˜๊ฒฝ์—์„œ๋งŒ ๋ฐ”๋กœ ACTIVE ์ƒํƒœ๋กœ ์„ค์ • + if (isDevEnvironment()) { + log.info("dev ํ™˜๊ฒฝ ๊ฐ์ง€: ์‚ฌ์šฉ์ž ์ž๋™ ์Šน์ธ ์ฒ˜๋ฆฌ (userId: {})", user.getId()); + user.accept(); + } + } + + /** + * ํ˜„์žฌ ํ™˜๊ฒฝ์ด dev ํ”„๋กœํŒŒ์ผ์ธ์ง€ ํ™•์ธ + * @return dev ํ”„๋กœํŒŒ์ผ์ด ํ™œ์„ฑํ™”๋˜์–ด ์žˆ์œผ๋ฉด true + */ + private boolean isDevEnvironment() { + String[] activeProfiles = environment.getActiveProfiles(); + for (String profile : activeProfiles) { + if ("dev".equals(profile)) { + return true; + } + if ("local".equals(profile)) { + return true; + } + } + return false; + } } diff --git a/src/main/java/leets/weeth/domain/user/domain/entity/User.java b/src/main/java/leets/weeth/domain/user/domain/entity/User.java index 22e020bed..9b5a137e9 100644 --- a/src/main/java/leets/weeth/domain/user/domain/entity/User.java +++ b/src/main/java/leets/weeth/domain/user/domain/entity/User.java @@ -1,20 +1,6 @@ package leets.weeth.domain.user.domain.entity; -import static leets.weeth.domain.user.application.dto.request.UserRequestDto.Update; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.PrePersist; -import jakarta.persistence.Table; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.*; import leets.weeth.domain.attendance.domain.entity.Attendance; import leets.weeth.domain.board.domain.entity.enums.Part; import leets.weeth.domain.user.domain.entity.enums.Department; @@ -29,6 +15,11 @@ import lombok.experimental.SuperBuilder; import org.springframework.security.crypto.password.PasswordEncoder; +import java.util.ArrayList; +import java.util.List; + +import static leets.weeth.domain.user.application.dto.request.UserRequestDto.Update; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -42,8 +33,12 @@ public class User extends BaseEntity { @Column(name = "user_id") private Long id; + @Column(unique = true) private Long kakaoId; + @Column(unique = true) + private String appleId; + private String name; private String email; @@ -94,6 +89,10 @@ public void addKakaoId(long kakaoId) { this.kakaoId = kakaoId; } + public void addAppleId(String appleId) { + this.appleId = appleId; + } + public void leave() { this.status = Status.LEFT; } diff --git a/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java b/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java index 23c61e5a2..8881f0a4e 100644 --- a/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java +++ b/src/main/java/leets/weeth/domain/user/domain/repository/UserRepository.java @@ -18,6 +18,8 @@ public interface UserRepository extends JpaRepository { Optional findByKakaoId(long kakaoId); + Optional findByAppleId(String appleId); + ListfindAllByNameContainingAndStatus(String name, Status status); boolean existsByEmail(String email); diff --git a/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java b/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java index 15fe07f92..1498f0a62 100644 --- a/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java +++ b/src/main/java/leets/weeth/domain/user/domain/service/UserGetService.java @@ -33,6 +33,10 @@ public Optional find(long kakaoId){ return userRepository.findByKakaoId(kakaoId); } + public Optional findByAppleId(String appleId){ + return userRepository.findByAppleId(appleId); + } + public List search(String keyword) { return userRepository.findAllByNameContainingAndStatus(keyword, Status.ACTIVE); } diff --git a/src/main/java/leets/weeth/domain/user/presentation/UserController.java b/src/main/java/leets/weeth/domain/user/presentation/UserController.java index 269293cd0..9c131428a 100644 --- a/src/main/java/leets/weeth/domain/user/presentation/UserController.java +++ b/src/main/java/leets/weeth/domain/user/presentation/UserController.java @@ -68,6 +68,20 @@ public CommonResponse integrate(@RequestBody @Valid NormalL return CommonResponse.createSuccess(SOCIAL_INTEGRATE_SUCCESS.getMessage(), userUseCase.integrate(dto)); } + @PostMapping("/apple/login") + @Operation(summary = "์• ํ”Œ ์†Œ์…œ ๋กœ๊ทธ์ธ API") + public CommonResponse appleLogin(@RequestBody @Valid Login dto) { + SocialLoginResponse response = userUseCase.appleLogin(dto); + return CommonResponse.createSuccess(SOCIAL_LOGIN_SUCCESS.getMessage(), response); + } + + @PostMapping("/apple/register") + @Operation(summary = "์• ํ”Œ ์†Œ์…œ ํšŒ์›๊ฐ€์ž… (dev ์ „์šฉ - ๋ฐ”๋กœ ACTIVE)") + public CommonResponse appleRegister(@RequestBody @Valid Register dto) { + userUseCase.appleRegister(dto); + return CommonResponse.createSuccess(USER_APPLY_SUCCESS.getMessage()); + } + @GetMapping("/email") @Operation(summary = "์ด๋ฉ”์ผ ์ค‘๋ณต ํ™•์ธ") public CommonResponse checkEmail(@RequestParam String email) { diff --git a/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java b/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java new file mode 100644 index 000000000..160788da2 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/AppleAuthService.java @@ -0,0 +1,247 @@ +package leets.weeth.global.auth.apple; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import leets.weeth.global.auth.apple.dto.ApplePublicKey; +import leets.weeth.global.auth.apple.dto.ApplePublicKeys; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; +import leets.weeth.global.auth.apple.exception.AppleAuthenticationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClient; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +@Service +@Slf4j +public class AppleAuthService { + + @Value("${auth.providers.apple.client_id}") + private String appleClientId; + + @Value("${auth.providers.apple.team_id}") + private String appleTeamId; + + @Value("${auth.providers.apple.key_id}") + private String appleKeyId; + + @Value("${auth.providers.apple.redirect_uri}") + private String redirectUri; + + @Value("${auth.providers.apple.token_uri}") + private String tokenUri; + + @Value("${auth.providers.apple.keys_uri}") + private String keysUri; + + @Value("${auth.providers.apple.private_key_path}") + private String privateKeyPath; + + private final RestClient restClient = RestClient.create(); + + /** + * Authorization code๋กœ ์• ํ”Œ ํ† ํฐ ์š”์ฒญ + * client_secret์€ JWT๋กœ ์ƒ์„ฑ (ES256 ์•Œ๊ณ ๋ฆฌ์ฆ˜) + */ + public AppleTokenResponse getAppleToken(String authCode) { + String clientSecret = generateClientSecret(); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("client_id", appleClientId); + body.add("client_secret", clientSecret); + body.add("code", authCode); + body.add("redirect_uri", redirectUri); + + return restClient.post() + .uri(tokenUri) + .body(body) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .retrieve() + .body(AppleTokenResponse.class); + } + + /** + * ID Token ๊ฒ€์ฆ ๋ฐ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + * ์• ํ”Œ์€ ๋ณ„๋„ userInfo ์—”๋“œํฌ์ธํŠธ๊ฐ€ ์—†๊ณ  ID Token์— ์ •๋ณด๊ฐ€ ํฌํ•จ๋จ + */ + public AppleUserInfo verifyAndDecodeIdToken(String idToken) { + try { + // 1. ID Token์˜ ํ—ค๋”์—์„œ kid ์ถ”์ถœ + String[] tokenParts = idToken.split("\\."); + String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); + Map headerMap = parseJson(header); + String kid = (String) headerMap.get("kid"); + + // 2. ์• ํ”Œ ๊ณต๊ฐœํ‚ค ๊ฐ€์ ธ์˜ค๊ธฐ + ApplePublicKeys publicKeys = restClient.get() + .uri(keysUri) + .retrieve() + .body(ApplePublicKeys.class); + + // 3. kid์™€ ์ผ์น˜ํ•˜๋Š” ๊ณต๊ฐœํ‚ค ์ฐพ๊ธฐ + ApplePublicKey matchedKey = publicKeys.keys().stream() + .filter(key -> key.kid().equals(kid)) + .findFirst() + .orElseThrow(AppleAuthenticationException::new); + + // 4. ๊ณต๊ฐœํ‚ค๋กœ ID Token ๊ฒ€์ฆ + PublicKey publicKey = generatePublicKey(matchedKey); + Claims claims = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + + // 5. Claims ๊ฒ€์ฆ + validateClaims(claims); + + // 6. ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + String appleId = claims.getSubject(); + String email = claims.get("email", String.class); + Boolean emailVerified = claims.get("email_verified", Boolean.class); + + return AppleUserInfo.builder() + .appleId(appleId) + .email(email) + .emailVerified(emailVerified != null ? emailVerified : false) + .build(); + + } catch (Exception e) { + log.error("์• ํ”Œ ID Token ๊ฒ€์ฆ ์‹คํŒจ", e); + throw new AppleAuthenticationException(); + } + } + + /** + * ์• ํ”Œ ๋กœ๊ทธ์ธ์šฉ client_secret ์ƒ์„ฑ + * ES256 ์•Œ๊ณ ๋ฆฌ์ฆ˜์œผ๋กœ JWT ์ƒ์„ฑ (p8 ํ‚ค ํŒŒ์ผ ์‚ฌ์šฉ) + */ + private String generateClientSecret() { + try (InputStream inputStream = getInputStream(privateKeyPath)) { + // p8 ํŒŒ์ผ์—์„œ Private Key ์ฝ๊ธฐ + String privateKeyContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + + // PEM ํ˜•์‹์˜ ํ—ค๋”/ํ‘ธํ„ฐ ์ œ๊ฑฐ + privateKeyContent = privateKeyContent + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + // Private Key ๊ฐ์ฒด ์ƒ์„ฑ + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + PrivateKey privateKey = keyFactory.generatePrivate( + new java.security.spec.PKCS8EncodedKeySpec(keyBytes) + ); + + // JWT ์ƒ์„ฑ + LocalDateTime now = LocalDateTime.now(); + Date issuedAt = Date.from(now.atZone(ZoneId.systemDefault()).toInstant()); + Date expiration = Date.from(now.plusMonths(5).atZone(ZoneId.systemDefault()).toInstant()); + + return Jwts.builder() + .setHeaderParam("kid", appleKeyId) + .setHeaderParam("alg", "ES256") + .setIssuer(appleTeamId) + .setIssuedAt(issuedAt) + .setExpiration(expiration) + .setAudience("https://appleid.apple.com") + .setSubject(appleClientId) + .signWith(privateKey, SignatureAlgorithm.ES256) + .compact(); + + } catch (Exception e) { + log.error("์• ํ”Œ Client Secret ์ƒ์„ฑ ์‹คํŒจ", e); + throw new AppleAuthenticationException(); + } + } + + /** + * ํŒŒ์ผ ๊ฒฝ๋กœ์—์„œ InputStream ๊ฐ€์ ธ์˜ค๊ธฐ + * ์ ˆ๋Œ€ ๊ฒฝ๋กœ๋ฉด ํŒŒ์ผ ์‹œ์Šคํ…œ์—์„œ, ์ƒ๋Œ€ ๊ฒฝ๋กœ๋ฉด classpath์—์„œ ์ฝ์Œ + */ + private InputStream getInputStream(String path) throws IOException { + // ์ ˆ๋Œ€ ๊ฒฝ๋กœ์ธ ๊ฒฝ์šฐ ํŒŒ์ผ ์‹œ์Šคํ…œ์—์„œ ์ฝ๊ธฐ + if (path.startsWith("/") || path.matches("^[A-Za-z]:.*")) { + return new FileInputStream(path); + } + // ์ƒ๋Œ€ ๊ฒฝ๋กœ๋Š” classpath์—์„œ ์ฝ๊ธฐ + return new ClassPathResource(path).getInputStream(); + } + + /** + * ์• ํ”Œ ๊ณต๊ฐœํ‚ค๋กœ๋ถ€ํ„ฐ PublicKey ๊ฐ์ฒด ์ƒ์„ฑ + */ + private PublicKey generatePublicKey(ApplePublicKey applePublicKey) { + try { + byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e()); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + + return keyFactory.generatePublic(publicKeySpec); + } catch (Exception ex) { + log.error("์• ํ”Œ ๊ณต๊ฐœํ‚ค ์ƒ์„ฑ ์‹คํŒจ", ex); + throw new AppleAuthenticationException(); + } + } + + /** + * ID Token์˜ Claims ๊ฒ€์ฆ + */ + private void validateClaims(Claims claims) { + String iss = claims.getIssuer(); + String aud = claims.getAudience(); + + if (!iss.equals("https://appleid.apple.com")) { + throw new RuntimeException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฐœ๊ธ‰์ž(issuer)์ž…๋‹ˆ๋‹ค."); + } + + if (!aud.equals(appleClientId)) { + throw new RuntimeException("์œ ํšจํ•˜์ง€ ์•Š์€ ์ˆ˜์‹ ์ž(audience)์ž…๋‹ˆ๋‹ค."); + } + + Date expiration = claims.getExpiration(); + if (expiration.before(new Date())) { + throw new RuntimeException("๋งŒ๋ฃŒ๋œ ID Token์ž…๋‹ˆ๋‹ค."); + } + } + + /** + * JSON ๋ฌธ์ž์—ด์„ Map์œผ๋กœ ํŒŒ์‹ฑ + */ + @SuppressWarnings("unchecked") + private Map parseJson(String json) { + try { + com.fasterxml.jackson.databind.ObjectMapper objectMapper = new com.fasterxml.jackson.databind.ObjectMapper(); + return objectMapper.readValue(json, Map.class); + } catch (Exception e) { + throw new RuntimeException("JSON ํŒŒ์‹ฑ ์‹คํŒจ"); + } + } +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java new file mode 100644 index 000000000..54d687297 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKey.java @@ -0,0 +1,13 @@ +package leets.weeth.global.auth.apple.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record ApplePublicKey( + String kty, + String kid, + String use, + String alg, + String n, + String e +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java new file mode 100644 index 000000000..909d2fc58 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/ApplePublicKeys.java @@ -0,0 +1,8 @@ +package leets.weeth.global.auth.apple.dto; + +import java.util.List; + +public record ApplePublicKeys( + List keys +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java b/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java new file mode 100644 index 000000000..2c52a44a8 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/AppleTokenResponse.java @@ -0,0 +1,10 @@ +package leets.weeth.global.auth.apple.dto; + +public record AppleTokenResponse( + String access_token, + String token_type, + Long expires_in, + String refresh_token, + String id_token +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java b/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java new file mode 100644 index 000000000..8bec95699 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/dto/AppleUserInfo.java @@ -0,0 +1,11 @@ +package leets.weeth.global.auth.apple.dto; + +import lombok.Builder; + +@Builder +public record AppleUserInfo( + String appleId, + String email, + Boolean emailVerified +) { +} diff --git a/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java b/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java new file mode 100644 index 000000000..292165235 --- /dev/null +++ b/src/main/java/leets/weeth/global/auth/apple/exception/AppleAuthenticationException.java @@ -0,0 +1,9 @@ +package leets.weeth.global.auth.apple.exception; + +import leets.weeth.global.common.exception.BusinessLogicException; + +public class AppleAuthenticationException extends BusinessLogicException { + public AppleAuthenticationException() { + super(401, "์• ํ”Œ ๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); + } +} diff --git a/src/main/java/leets/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java b/src/main/java/leets/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java index 4097fca97..1e78d7546 100644 --- a/src/main/java/leets/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java +++ b/src/main/java/leets/weeth/global/auth/jwt/filter/JwtAuthenticationProcessingFilter.java @@ -47,6 +47,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (jwtProvider.validate(accessToken)) { saveAuthentication(accessToken); } + } catch (TokenNotFoundException e) { + log.debug("Token not found: {}", e.getMessage()); } catch (RuntimeException e) { log.info("error token: {}", e.getMessage()); } diff --git a/src/main/java/leets/weeth/global/config/SecurityConfig.java b/src/main/java/leets/weeth/global/config/SecurityConfig.java index d8e245f50..a0233f0d5 100644 --- a/src/main/java/leets/weeth/global/config/SecurityConfig.java +++ b/src/main/java/leets/weeth/global/config/SecurityConfig.java @@ -18,6 +18,7 @@ import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.authorization.AuthorizationDecision; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -73,13 +74,20 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests( authorize -> authorize - .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() + .requestMatchers("/api/v1/users/kakao/login", "api/v1/users/kakao/register", "api/v1/users/kakao/link", "/api/v1/users/apple/login", "/api/v1/users/apple/register", "/api/v1/users/apply", "/api/v1/users/email", "/api/v1/users/refresh").permitAll() .requestMatchers("/health-check").permitAll() - .requestMatchers("/oauth2/**", "/.well-known/**", "/kakao/oauth").permitAll() + .requestMatchers("/oauth2/**", "/.well-known/**", "/kakao/oauth", "/apple/oauth").permitAll() .requestMatchers("/admin", "/admin/login", "/admin/account", "/admin/meeting", "/admin/member", "/admin/penalty", "/js/**", "/img/**", "/scss/**", "/vendor/**").permitAll() // ์Šค์›จ๊ฑฐ ๊ฒฝ๋กœ .requestMatchers("/v3/api-docs", "/v3/api-docs/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**").permitAll() + .requestMatchers("/actuator/prometheus") + .access((authentication, context) -> { + String ip = context.getRequest().getRemoteAddr(); + boolean allowed = ip.startsWith("172.") || ip.equals("127.0.0.1"); + return new AuthorizationDecision(allowed); + }) + .requestMatchers("/actuator/health").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) diff --git a/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java b/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java new file mode 100644 index 000000000..6e3bd0aaa --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/application/exception/AppleLoginException.java @@ -0,0 +1,10 @@ +package leets.weeth.global.sas.application.exception; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +public class AppleLoginException extends OAuth2AuthenticationException { + public AppleLoginException(String message) { + super(new OAuth2Error(ErrorMessage.APPLE_AUTH_ERROR.getCode(), message, null)); + } +} diff --git a/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java b/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java index 82d24c54d..a897fa5d9 100644 --- a/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java +++ b/src/main/java/leets/weeth/global/sas/application/exception/ErrorMessage.java @@ -9,7 +9,8 @@ public enum ErrorMessage { USER_INACTIVE("WAE-001", "๊ฐ€์ž… ์Šน์ธ์ด ํ—ˆ๊ฐ€๋˜์ง€ ์•Š์€ ๊ณ„์ •์ž…๋‹ˆ๋‹ค."), USER_NOT_FOUND("WAE-002", "์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €์ž…๋‹ˆ๋‹ค."), - KAKAO_AUTH_ERROR("WAE-003", "์นด์นด์˜ค ๋กœ๊ทธ์ธ ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค."); + KAKAO_AUTH_ERROR("WAE-003", "์นด์นด์˜ค ๋กœ๊ทธ์ธ ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค."), + APPLE_AUTH_ERROR("WAE-004", "์• ํ”Œ ๋กœ๊ทธ์ธ ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค."); private final String code; private final String description; diff --git a/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java b/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java index 169d157eb..1f42d1239 100644 --- a/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java +++ b/src/main/java/leets/weeth/global/sas/application/mapper/OAuth2AuthorizationConverter.java @@ -1,6 +1,7 @@ package leets.weeth.global.sas.application.mapper; +import leets.weeth.global.sas.config.grant.AppleGrantType; import leets.weeth.global.sas.config.grant.KakaoGrantType; import leets.weeth.global.sas.domain.entity.*; import org.springframework.security.oauth2.core.AuthorizationGrantType; @@ -33,6 +34,10 @@ public static OAuth2AuthorizationGrantAuthorization convertOAuth2AuthorizationGr return convertKakaoAuthorizationGrantAuthorization(authorization); } + if (AppleGrantType.APPLE_IDENTITY_TOKEN.equals(grantType)) { + return convertAppleAuthorizationGrantAuthorization(authorization); + } + return null; } @@ -55,6 +60,25 @@ public static OAuth2AuthorizationGrantAuthorization convertOAuth2AuthorizationGr .build(); } + private static OidcAuthorizationCodeGrantAuthorization + convertAppleAuthorizationGrantAuthorization(OAuth2Authorization authorization) { + + AccessToken accessToken = extractAccessToken(authorization); + RefreshToken refreshToken = extractRefreshToken(authorization); + IdToken idToken = extractIdToken(authorization); + + return OidcAuthorizationCodeGrantAuthorization.builder() + .id(authorization.getId()) + .registeredClientId(authorization.getRegisteredClientId()) + .principalName(authorization.getPrincipalName()) + .authorizedScopes(authorization.getAuthorizedScopes()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .idToken(idToken) + .principal(authorization.getAttribute(Principal.class.getName())) + .build(); + } + static OidcAuthorizationCodeGrantAuthorization convertOidcAuthorizationCodeGrantAuthorization(OAuth2Authorization authorization) { AuthorizationCode authorizationCode = extractAuthorizationCode(authorization); AccessToken accessToken = extractAccessToken(authorization); diff --git a/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java b/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java index 22de81b81..a110df157 100644 --- a/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java +++ b/src/main/java/leets/weeth/global/sas/application/usecase/AuthUsecase.java @@ -6,6 +6,9 @@ import leets.weeth.domain.user.domain.entity.User; import leets.weeth.domain.user.domain.service.UserCardinalGetService; import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; +import leets.weeth.global.auth.apple.dto.AppleTokenResponse; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; import leets.weeth.global.auth.jwt.service.JwtService; import leets.weeth.global.auth.kakao.KakaoAuthService; import leets.weeth.global.auth.kakao.dto.KakaoTokenResponse; @@ -22,6 +25,7 @@ public class AuthUsecase { private final KakaoAuthService kakaoAuthService; + private final AppleAuthService appleAuthService; private final UserGetService userGetService; private final JwtService jwtService; private final UserCardinalGetService userCardinalGetService; @@ -51,6 +55,32 @@ public User login(String authCode) { return user; } + /* + ํ•„์š” ์—†์Œ + */ + public User appleLogin(String authCode, String idToken) { + AppleTokenResponse tokenResponse = appleAuthService.getAppleToken(authCode); + + // ID Token ์‚ฌ์šฉ + String token = idToken != null ? idToken : tokenResponse.id_token(); + AppleUserInfo userInfo = appleAuthService.verifyAndDecodeIdToken(token); + + String appleId = userInfo.appleId(); + Optional optionalUser = userGetService.findByAppleId(appleId); + + if (optionalUser.isEmpty()) { + throw new UserNotFoundException(); // -> Weeth ํšŒ์›๊ฐ€์ž… ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ + } + + User user = optionalUser.get(); + + if (user.isInactive()) { + throw new UserInActiveException(); // -> ๊ฐ€์ž… ์Šน์ธ ๋Œ€๊ธฐ + } + + return user; + } + public OauthUserInfoResponse userInfo(String accessToken) { String token = accessToken.substring(7); diff --git a/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java b/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java index 86183202b..a1d39fa6f 100644 --- a/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java +++ b/src/main/java/leets/weeth/global/sas/config/OAuth2AuthorizationServerConfig.java @@ -5,13 +5,12 @@ import com.nimbusds.jose.proc.SecurityContext; import leets.weeth.domain.user.domain.entity.SecurityUser; import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; import leets.weeth.global.auth.kakao.KakaoAuthService; import leets.weeth.global.sas.application.exception.Oauth2JwtTokenException; import leets.weeth.global.sas.application.property.OauthProperties; import leets.weeth.global.sas.config.authentication.ProviderAwareEntryPoint; -import leets.weeth.global.sas.config.grant.KakaoAccessTokenAuthenticationConverter; -import leets.weeth.global.sas.config.grant.KakaoAuthenticationProvider; -import leets.weeth.global.sas.config.grant.KakaoGrantType; +import leets.weeth.global.sas.config.grant.*; import leets.weeth.global.sas.domain.repository.OAuth2AuthorizationGrantAuthorizationRepository; import leets.weeth.global.sas.domain.service.RedisOAuth2AuthorizationService; import lombok.RequiredArgsConstructor; @@ -31,7 +30,6 @@ import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; -import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration; import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer; import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; @@ -56,6 +54,7 @@ public class OAuth2AuthorizationServerConfig { private final ProviderAwareEntryPoint entryPoint; private final KakaoAuthService kakaoAuthService; + private final AppleAuthService appleAuthService; private final UserGetService userGetService; private final OauthProperties props; @@ -68,25 +67,57 @@ public class OAuth2AuthorizationServerConfig { @Order(1) // ์šฐ์„ ์ˆœ์œ„๋ฅผ ๊ธฐ๋ณธ filter๋ณด๋‹ค ๋†’๊ฒŒ ์„ค์ • public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http, KakaoAccessTokenAuthenticationConverter kakaoConverter, - KakaoAuthenticationProvider kakaoProvider) throws Exception { - OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); + KakaoAuthenticationProvider kakaoProvider, + AppleIdentityTokenAuthenticationConverter appleConverter, + AppleAuthenticationProvider appleProvider) throws Exception { // entryPoint ์ฃผ์ž… - http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) - .oidc(Customizer.withDefaults()) - .tokenEndpoint(token -> token - .accessTokenRequestConverters(c -> c.add(kakaoConverter)) - .authenticationProviders(p -> p.add(kakaoProvider)) - ); + // 1. Configurer ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ (๊ณต์‹ ํ…œํ”Œ๋ฆฟ ๋ฐฉ์‹) + OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = + OAuth2AuthorizationServerConfigurer.authorizationServer(); - http.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())); + http + // 2. ์ด ํ•„ํ„ฐ์ฒด์ธ์ด ์ ์šฉ๋  ์—”๋“œํฌ์ธํŠธ๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ • (ํ…œํ”Œ๋ฆฟ ๋ฐฉ์‹) + .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) - // ์ปค์Šคํ…€ EntryPoint (provider ํŒŒ๋ผ๋ฏธํ„ฐ ํ•ด์„ โ†’ 302 /oauth2/authorization/{provider}) - http.exceptionHandling(e -> e.defaultAuthenticationEntryPointFor( - entryPoint, rq -> rq.getRequestURI().startsWith("/oauth2/authorize"))); + // 3. .with()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ Configurer ์ ์šฉ ๋ฐ ์ปค์Šคํ…€ (ํ…œํ”Œ๋ฆฟ ๋ฐฉ์‹) + .with(authorizationServerConfigurer, (authorizationServer) -> + authorizationServer + .oidc(Customizer.withDefaults()) // OIDC ํ™œ์„ฑํ™” - return http - .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/.well-known/**")) - .build(); + // 4. [์‚ฌ์šฉ์ž ์ •์˜] ํ† ํฐ ์—”๋“œํฌ์ธํŠธ ์ปค์Šคํ…€ ๋กœ์ง ์‚ฝ์ž… + .tokenEndpoint(token -> token + .accessTokenRequestConverters(c -> { + c.add(kakaoConverter); + c.add(appleConverter); + }) + .authenticationProviders(p -> { + p.add(kakaoProvider); + p.add(appleProvider); + }) + ) + ) + + // 5. ์—”๋“œํฌ์ธํŠธ์— ๋Œ€ํ•œ ๊ธฐ๋ณธ ์ธ์ฆ ์š”๊ตฌ (ํ…œํ”Œ๋ฆฟ ๋ฐฉ์‹) + .authorizeHttpRequests((authorize) -> + authorize.anyRequest().authenticated() + ) + + // 6. [์‚ฌ์šฉ์ž ์ •์˜] ๋ฆฌ์†Œ์Šค ์„œ๋ฒ„ ์„ค์ • (JWT ๊ฒ€์ฆ) + .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults())) + + // 7. [์‚ฌ์šฉ์ž ์ •์˜] ์ธ์ฆ ์‹คํŒจ ์‹œ ์ปค์Šคํ…€ EntryPoint ์‚ฌ์šฉ (ํ…œํ”Œ๋ฆฟ ๊ตฌ์กฐ + ์‚ฌ์šฉ์ž ๋กœ์ง) + // (ํ…œํ”Œ๋ฆฟ์˜ /login ๋ฆฌ๋””๋ ‰์…˜ ๋Œ€์‹ , ๊ธฐ์กด์˜ provider ๋ถ„๊ธฐ ๋กœ์ง์„ ์‚ฌ์šฉ) + .exceptionHandling((exceptions) -> exceptions + .defaultAuthenticationEntryPointFor( + entryPoint, // ์‚ฌ์šฉ์ž์˜ ์ปค์Šคํ…€ EntryPoint + rq -> rq.getRequestURI().startsWith("/oauth2/authorize") // ์‚ฌ์šฉ์ž์˜ ์ปค์Šคํ…€ Predicate + ) + ) + + // 8. [์‚ฌ์šฉ์ž ์ •์˜] CSRF ์„ค์ • + .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/**", "/.well-known/**")); + + return http.build(); } @Bean @@ -104,6 +135,7 @@ public RegisteredClientRepository registeredClientRepository() { type.add(AuthorizationGrantType.AUTHORIZATION_CODE); type.add(AuthorizationGrantType.REFRESH_TOKEN); type.add(KakaoGrantType.KAKAO_ACCESS_TOKEN); + type.add(AppleGrantType.APPLE_IDENTITY_TOKEN); }) .redirectUris(uri -> { uri.addAll(leenk.getRedirectUris()); @@ -203,6 +235,20 @@ KakaoAuthenticationProvider kakaoProvider( kakaoAuthService, userGetService, authorizationService, tokenGenerator); } + @Bean + AppleIdentityTokenAuthenticationConverter appleConverter() { + return new AppleIdentityTokenAuthenticationConverter(); + } + + @Bean + AppleAuthenticationProvider appleProvider( + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator) { + + return new AppleAuthenticationProvider( + appleAuthService, userGetService, authorizationService, tokenGenerator); + } + private RSAKey loadRsaKeyFromString() { try { return new RSAKey.Builder(publicKey) diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java new file mode 100644 index 000000000..d18db726c --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleAuthenticationProvider.java @@ -0,0 +1,73 @@ +package leets.weeth.global.sas.config.grant; + +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.global.auth.apple.AppleAuthService; +import leets.weeth.global.auth.apple.dto.AppleUserInfo; +import leets.weeth.global.sas.application.exception.AppleLoginException; +import leets.weeth.global.sas.application.exception.UserInActiveException; +import leets.weeth.global.sas.application.exception.UserNotFoundException; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.OAuth2Token; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator; +import org.springframework.stereotype.Component; + +@Component +public class AppleAuthenticationProvider extends CustomAuthenticationProvider { + + private final AppleAuthService appleAuthService; + private final UserGetService userGetService; + + public AppleAuthenticationProvider( + AppleAuthService appleAuthService, + UserGetService userGetService, + OAuth2AuthorizationService authorizationService, + OAuth2TokenGenerator tokenGenerator + ) { + super(authorizationService, tokenGenerator); + this.appleAuthService = appleAuthService; + this.userGetService = userGetService; + } + + @Override + protected AuthorizationGrantType getGrantTokenType() { + return AppleGrantType.APPLE_IDENTITY_TOKEN; + } + + @Override + protected Class getAuthenticationClass() { + return AppleIdentityTokenAuthenticationToken.class; + } + + @Override + protected String extractAccessToken(Authentication authentication) { + AppleIdentityTokenAuthenticationToken grantAuth = + (AppleIdentityTokenAuthenticationToken) authentication; + return grantAuth.getAppleIdentityToken(); + } + + @Override + protected AppleUserInfo getUserInfo(String identityToken) { + try { + // Identity Token ๊ฒ€์ฆ ๋ฐ ์‚ฌ์šฉ์ž ์ •๋ณด ์ถ”์ถœ + return appleAuthService.verifyAndDecodeIdToken(identityToken); + } catch (Exception e) { + throw new AppleLoginException(e.getMessage()); + } + } + + @Override + protected User getOrLoadUser(AppleUserInfo userInfo) { + String appleId = userInfo.appleId(); + User user = userGetService.findByAppleId(appleId) + .orElseThrow(UserNotFoundException::new); + + if (user.isInactive()) { + throw new UserInActiveException(); + } + + return user; + } +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java new file mode 100644 index 000000000..78155dfa0 --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleGrantType.java @@ -0,0 +1,10 @@ +package leets.weeth.global.sas.config.grant; + +import org.springframework.security.oauth2.core.AuthorizationGrantType; + +public final class AppleGrantType { + public static final AuthorizationGrantType APPLE_IDENTITY_TOKEN = + new AuthorizationGrantType("apple_identity_token"); + + private AppleGrantType() {} +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java new file mode 100644 index 000000000..dd9c5cdcc --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationConverter.java @@ -0,0 +1,37 @@ +package leets.weeth.global.sas.config.grant; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.security.web.authentication.AuthenticationConverter; +import org.springframework.util.StringUtils; + +import java.util.HashMap; + +public class AppleIdentityTokenAuthenticationConverter implements AuthenticationConverter { + + @Override + public Authentication convert(HttpServletRequest request) { + if (!AppleGrantType.APPLE_IDENTITY_TOKEN.getValue() + .equals(request.getParameter(OAuth2ParameterNames.GRANT_TYPE))) { + return null; + } + + String identityToken = request.getParameter("identity_token"); + if (!StringUtils.hasText(identityToken)) { + throw new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_REQUEST); + } + + Authentication clientPrincipal = (Authentication) request.getUserPrincipal(); + + var additional = new HashMap(); + request.getParameterMap().forEach((k, v) -> { + if (!OAuth2ParameterNames.GRANT_TYPE.equals(k) && !"identity_token".equals(k)) + additional.put(k, v[0]); + }); + + return new AppleIdentityTokenAuthenticationToken(identityToken, clientPrincipal, additional); + } +} diff --git a/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java new file mode 100644 index 000000000..02947407f --- /dev/null +++ b/src/main/java/leets/weeth/global/sas/config/grant/AppleIdentityTokenAuthenticationToken.java @@ -0,0 +1,22 @@ +package leets.weeth.global.sas.config.grant; + +import lombok.Getter; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken; + +import java.util.Map; + +@Getter +public class AppleIdentityTokenAuthenticationToken extends OAuth2AuthorizationGrantAuthenticationToken { + + private final String appleIdentityToken; + + public AppleIdentityTokenAuthenticationToken( + String appleIdentityToken, + Authentication clientPrincipal, + Map additionalParameters) { + super(new AuthorizationGrantType("apple_identity_token"), clientPrincipal, additionalParameters); + this.appleIdentityToken = appleIdentityToken; + } +} diff --git a/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java b/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java index 4909fc5aa..a361ed0b9 100644 --- a/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java +++ b/src/main/java/leets/weeth/global/sas/domain/service/RedisOAuth2AuthorizationService.java @@ -36,9 +36,15 @@ public void save(OAuth2Authorization authorization) { // ttl ์„ค์ • if (entity.getAccessToken() == null && entity instanceof OAuth2AuthorizationCodeGrantAuthorization codeGrant) { - entity.updateExpire(calculateTtlSeconds(((OAuth2AuthorizationCodeGrantAuthorization) entity).getAuthorizationCode().getExpiresAt())); - } else { + entity.updateExpire(calculateTtlSeconds(codeGrant.getAuthorizationCode().getExpiresAt())); + } else if (entity.getRefreshToken() != null) { entity.updateExpire(calculateTtlSeconds(entity.getRefreshToken().getExpiresAt())); + } else if (entity.getAccessToken() != null) { + // refresh token์ด ์—†์œผ๋ฉด access token์˜ ๋งŒ๋ฃŒ ์‹œ๊ฐ„ ์‚ฌ์šฉ + entity.updateExpire(calculateTtlSeconds(entity.getAccessToken().getExpiresAt())); + } else { + // access token๋„ ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ 1์‹œ๊ฐ„ ์„ค์ • + entity.updateExpire(3600L); } this.authorizationGrantAuthorizationRepository.save(entity); diff --git a/src/main/java/leets/weeth/global/sas/presentation/AuthController.java b/src/main/java/leets/weeth/global/sas/presentation/AuthController.java index 6329292f1..8627b09f1 100644 --- a/src/main/java/leets/weeth/global/sas/presentation/AuthController.java +++ b/src/main/java/leets/weeth/global/sas/presentation/AuthController.java @@ -7,7 +7,6 @@ import leets.weeth.global.sas.application.dto.OauthUserInfoResponse; import leets.weeth.global.sas.application.usecase.AuthUsecase; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -47,6 +46,24 @@ public void kakaoCallback(@RequestParam String code, savedRequestHandler.onAuthenticationSuccess(request, response, auth); } + @GetMapping("/apple/oauth") + public void appleCallback(@RequestParam String code, + @RequestParam(required = false) String id_token, + HttpServletRequest request, + HttpServletResponse response) throws Exception { + + User findUser = authUsecase.appleLogin(code, id_token); + + Authentication auth = new UsernamePasswordAuthenticationToken(SecurityUser.from(findUser), null, List.of(new SimpleGrantedAuthority(findUser.getRole().name()))); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + + savedRequestHandler.onAuthenticationSuccess(request, response, auth); + } + @GetMapping("/user/me") public OauthUserInfoResponse userInfo(@RequestHeader("Authorization") String accessToken) { return authUsecase.userInfo(accessToken); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3de76ac2e..94b3fd9b0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,14 @@ auth: grant_type: ${KAKAO_GRANT_TYPE} token_uri: ${KAKAO_TOKEN_URI} user_info_uri: ${KAKAO_USER_INFO_URI} + apple: + client_id: ${APPLE_CLIENT_ID} + team_id: ${APPLE_TEAM_ID} + key_id: ${APPLE_KEY_ID} + redirect_uri: ${APPLE_REDIRECT_URI} + token_uri: https://appleid.apple.com/auth/token + keys_uri: https://appleid.apple.com/auth/keys + private_key_path: ${APPLE_PRIVATE_KEY_PATH} jwt: private-key: ${JWT_PRIVATE_KEY} public-key: ${JWT_PUBLIC_KEY} @@ -39,3 +47,15 @@ auth: scopes: ${LEENK_SCOPES} accessTokenTtl: ${LEENK_ACCESS_TOKEN_TTL} refreshTokenTtl: ${LEENK_REFRESH_TOKEN_TTL} + +management: + endpoints: + web: + exposure: + include: + - health + - prometheus + prometheus: + metrics: + export: + enabled: true diff --git a/src/test/java/leets/weeth/config/TestContainersConfig.java b/src/test/java/leets/weeth/config/TestContainersConfig.java new file mode 100644 index 000000000..273f39d42 --- /dev/null +++ b/src/test/java/leets/weeth/config/TestContainersConfig.java @@ -0,0 +1,20 @@ +package leets.weeth.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.mysql.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +@TestConfiguration +public class TestContainersConfig { + + private static final String MYSQL_IMAGE = "mysql:8.0.41"; + + @Bean + @ServiceConnection + public MySQLContainer mysqlContainer() { + return new MySQLContainer(DockerImageName.parse(MYSQL_IMAGE)) + .withReuse(true); + } +} diff --git a/src/test/java/leets/weeth/config/TestContainersTest.java b/src/test/java/leets/weeth/config/TestContainersTest.java new file mode 100644 index 000000000..80846e01e --- /dev/null +++ b/src/test/java/leets/weeth/config/TestContainersTest.java @@ -0,0 +1,25 @@ +package leets.weeth.config; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.testcontainers.mysql.MySQLContainer; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class TestContainersTest { + + @Autowired + private MySQLContainer mysqlContainer; + + @Test + void ์„ค์ •ํŒŒ์ผ๋กœ_์ฃผ์ž…๋œ_์ปจํ…Œ์ด๋„ˆ_์ •์ƒ_๋™์ž‘_ํ…Œ์ŠคํŠธ() { + assertThat(mysqlContainer).isNotNull(); + assertThat(mysqlContainer.isRunning()).isTrue(); + System.out.println("Container JDBC URL: " + mysqlContainer.getJdbcUrl()); + } +} diff --git a/src/test/java/leets/weeth/domain/attendance/application/mapper/AttendanceMapperTest.java b/src/test/java/leets/weeth/domain/attendance/application/mapper/AttendanceMapperTest.java new file mode 100644 index 000000000..20578feee --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/application/mapper/AttendanceMapperTest.java @@ -0,0 +1,124 @@ +package leets.weeth.domain.attendance.application.mapper; + +import static leets.weeth.domain.attendance.test.fixture.AttendanceTestFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import leets.weeth.domain.attendance.application.dto.AttendanceDTO; +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.enums.Position; + +class AttendanceMapperTest { + + private final AttendanceMapper attendanceMapper = new AttendanceMapperImpl(); + + @Test + @DisplayName("toMainDto: ์‚ฌ์šฉ์ž + ๋‹น์ผ ์ถœ์„ ๊ฐ์ฒด๋ฅผ Main DTO๋กœ ๋งคํ•‘ํ•œ๋‹ค") + void toMainDto_mapsUserAndTodayAttendance() { + // given + LocalDate today = LocalDate.now(); + Meeting meeting = createOneDayMeeting(today, 1, 1111, "Today"); + User user = createActiveUserWithAttendances("์ด์ง€ํ›ˆ", List.of(meeting)); + Attendance attendance = user.getAttendances().get(0); + + // when + AttendanceDTO.Main main = attendanceMapper.toMainDto(user, attendance); + + // then + assertThat(main).isNotNull(); + assertThat(main.title()).isEqualTo("Today"); + assertThat(main.status()).isEqualTo(attendance.getStatus()); + assertThat(main.start()).isEqualTo(meeting.getStart()); + assertThat(main.end()).isEqualTo(meeting.getEnd()); + assertThat(main.location()).isEqualTo(meeting.getLocation()); + } + + @Test + @DisplayName("toResponseDto: ๋‹จ์ผ ์ถœ์„์„ Response DTO๋กœ ๋งคํ•‘ํ•œ๋‹ค") + void toResponseDto_mapsSingleAttendance() { + // given + Meeting meeting = createOneDayMeeting(LocalDate.now().minusDays(1), 1, 2222, "D-1"); + User user = createActiveUser("์‚ฌ์šฉ์žA"); + Attendance attendance = createAttendance(meeting, user); + + // when + AttendanceDTO.Response response = attendanceMapper.toResponseDto(attendance); + + // then + assertThat(response).isNotNull(); + assertThat(response.title()).isEqualTo("D-1"); + assertThat(response.start()).isEqualTo(meeting.getStart()); + assertThat(response.end()).isEqualTo(meeting.getEnd()); + assertThat(response.location()).isEqualTo(meeting.getLocation()); + } + + @Test + @DisplayName("toDetailDto: ์‚ฌ์šฉ์ž + Response ๋ฆฌ์ŠคํŠธ๋ฅผ Detail DTO๋กœ ๋งคํ•‘(total = attend + absence)") + void toDetailDto_mapsDetailAndTotal() { + // given + LocalDate base = LocalDate.now(); + Meeting m1 = createOneDayMeeting(base.minusDays(2), 1, 1000, "D-2"); + Meeting m2 = createOneDayMeeting(base.minusDays(1), 1, 1001, "D-1"); + User user = createActiveUser("์ด์ง€ํ›ˆ"); + setUserAttendanceStats(user, 3, 2); + + Attendance a1 = createAttendance(m1, user); + Attendance a2 = createAttendance(m2, user); + + AttendanceDTO.Response r1 = attendanceMapper.toResponseDto(a1); + AttendanceDTO.Response r2 = attendanceMapper.toResponseDto(a2); + + // when + AttendanceDTO.Detail detail = attendanceMapper.toDetailDto(user, List.of(r1, r2)); + + // then + assertThat(detail).isNotNull(); + assertThat(detail.attendances()).hasSize(2); + assertThat(detail.total()).isEqualTo(5); + } + + @Test + @DisplayName("toAttendanceInfoDto: Attendance๋ฅผ Info DTO๋กœ ๋งคํ•‘") + void toAttendanceInfoDto_mapsInfo() { + // given + Meeting meeting = createOneDayMeeting(LocalDate.now(), 1, 3333, "Info"); + User user = createActiveUser("์œ ์ €B"); + enrichUserProfile(user, Position.BE, "์ปดํ“จํ„ฐ๊ณตํ•™๊ณผ", "20201234"); + + Attendance attendance = createAttendance(meeting, user); + setAttendanceId(attendance, 10L); + + // when + AttendanceDTO.AttendanceInfo info = attendanceMapper.toAttendanceInfoDto(attendance); + + // then + assertThat(info).isNotNull(); + assertThat(info.id()).isEqualTo(10L); + assertThat(info.status()).isEqualTo(attendance.getStatus()); + assertThat(info.name()).isEqualTo("์œ ์ €B"); + } + + @Test + @DisplayName("null ์•ˆ์ „์„ฑ ํ…Œ์ŠคํŠธ: todayAttendance๊ฐ€ null์ด๋ฉด ํ•„๋“œ๋Š” null๋กœ ๋งคํ•‘") + void nullSafety_whenTodayAttendanceNull() { + // given + User user = createActiveUser("์ด์ง€ํ›ˆ"); + + // when + AttendanceDTO.Main main = attendanceMapper.toMainDto(user, null); + + // then + assertThat(main).isNotNull(); + assertThat(main.title()).isNull(); + assertThat(main.start()).isNull(); + assertThat(main.end()).isNull(); + assertThat(main.location()).isNull(); + } +} \ No newline at end of file diff --git a/src/test/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.java b/src/test/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.java new file mode 100644 index 000000000..eb3957ca5 --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/application/usecase/AttendanceUseCaseImplTest.java @@ -0,0 +1,304 @@ +package leets.weeth.domain.attendance.application.usecase; + +import leets.weeth.domain.attendance.application.dto.AttendanceDTO; +import leets.weeth.domain.attendance.application.exception.AttendanceCodeMismatchException; +import leets.weeth.domain.attendance.application.exception.AttendanceNotFoundException; +import leets.weeth.domain.attendance.application.mapper.AttendanceMapper; +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.attendance.domain.entity.enums.Status; +import leets.weeth.domain.attendance.domain.service.AttendanceGetService; +import leets.weeth.domain.attendance.domain.service.AttendanceUpdateService; +import leets.weeth.domain.schedule.application.exception.MeetingNotFoundException; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.schedule.domain.service.MeetingGetService; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.service.UserCardinalGetService; +import leets.weeth.domain.user.domain.service.UserGetService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static leets.weeth.domain.attendance.test.fixture.AttendanceTestFixture.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +public class AttendanceUseCaseImplTest { + + private final Long userId = 10L; + @Mock private UserGetService userGetService; + @Mock private UserCardinalGetService userCardinalGetService; + @Mock private AttendanceGetService attendanceGetService; + @Mock private AttendanceUpdateService attendanceUpdateService; + @Mock private AttendanceMapper attendanceMapper; + @Mock private MeetingGetService meetingGetService; + @InjectMocks private AttendanceUseCaseImpl attendanceUseCase; + + @Test + @DisplayName("find: ์—ฌ๋Ÿฌ ๋‚ ์งœ์˜ ์ถœ์„ ๋ชฉ๋ก ์ค‘ '์‹œ์ž‘/์ข…๋ฃŒ ๋‚ ์งœ๊ฐ€ ๋ชจ๋‘ ์˜ค๋Š˜'์ธ ์ถœ์„์ •๋ณด๋ฅผ ์„ ํƒ") + void find_todayMeeting_filtersDispersedAttendances() { + // given + LocalDate today = LocalDate.now(); + + Meeting meetingYesterday = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday"); + Meeting meetingToday = createOneDayMeeting(today, 1, 2222, "Today"); + Meeting meetingTomorrow = createOneDayMeeting(today.plusDays(1),1, 3333, "Tomorrow"); + + User user = createActiveUserWithAttendances( + "์ด์ง€ํ›ˆ", List.of(meetingYesterday, meetingToday, meetingTomorrow) + ); + + Attendance expectedTodayAttendance = user.getAttendances().stream() + .filter(attendance -> "Today".equals(attendance.getMeeting().getTitle())) + .findFirst() + .orElseThrow(); + + AttendanceDTO.Main mapped = mock(AttendanceDTO.Main.class); + + given(userGetService.find(userId)).willReturn(user); + given(attendanceMapper.toMainDto(eq(user), eq(expectedTodayAttendance))).willReturn(mapped); + + // when + AttendanceDTO.Main actual = attendanceUseCase.find(userId); + + // then + assertThat(actual).isSameAs(mapped); + then(attendanceMapper).should().toMainDto(eq(user), eq(expectedTodayAttendance)); + then(attendanceMapper).shouldHaveNoMoreInteractions(); + } + + @Test + @DisplayName("10๋ถ„ ์ „๋ถ€ํ„ฐ ์ถœ์„์ด ๊ฐ€๋Šฅํ•œ์ง€ ํ™•์ธ") + void checkIn_10MinutesBeforeMeeting_ShouldSucceed() { + // given + LocalDateTime now = LocalDateTime.now(); + Meeting meeting = Meeting.builder() + .start(now.plusMinutes(5)) // 5๋ถ„ ๋’ค ์‹œ์ž‘ (checkIn ๋กœ์ง์˜ '10๋ถ„ ์ „' ๋ฒ”์œ„ ๋‚ด) + .end(now.plusHours(2)) + .code(1234) + .title("Today") + .cardinal(1) + .build(); + + User user = createActiveUserWithAttendances( + "์ด์ง€ํ›ˆ", List.of(meeting) + ); + + when(userGetService.find(userId)).thenReturn(user); + + + // when & then + assertDoesNotThrow(() -> attendanceUseCase.checkIn(userId, 1234)); + verify(attendanceUpdateService, times(1)).attend(any(Attendance.class)); + } + + @Test + @DisplayName("11๋ถ„ ์ „์— ์ถœ์„์‹œ ์˜ค๋ฅ˜ ํ™•์ธ") + void checkIn_10MinutesBeforeMeeting_ShouldFailed() { + // given + LocalDateTime now = LocalDateTime.now(); + Meeting meeting = Meeting.builder() + .start(now.plusMinutes(11)) // 11๋ถ„๋’ค ์‹œ์ž‘ -> ์˜ค๋ฅ˜ ๋ฐœ์ƒํ•ด์•ผํ•จ + .end(now.plusHours(2)) + .code(1234) + .title("Today") + .cardinal(1) + .build(); + + User user = createActiveUserWithAttendances( + "์ด์ง€ํ›ˆ", List.of(meeting) + ); + + when(userGetService.find(userId)).thenReturn(user); + + + // when & then + assertThatThrownBy(() -> attendanceUseCase.checkIn(userId, 1234)) + .isInstanceOf(AttendanceNotFoundException.class); + } + @Test + @DisplayName("find: '์‹œ์ž‘/์ข…๋ฃŒ ๋‚ ์งœ๊ฐ€ ๋ชจ๋‘ ์˜ค๋Š˜'์ธ ์ถœ์„์ด ์—†๋‹ค๋ฉด, mapper.toMainDto(user, null)์„ ํ˜ธ์ถœ") + void find_noExactToday_returnsNullMapped() { + // given + LocalDate today = LocalDate.now(); + + Meeting yesterdayMeeting = createOneDayMeeting(today.minusDays(1), 1, 1111, "Yesterday"); + Meeting tomorrowMeeting = createOneDayMeeting(today.plusDays(1), 1, 3333, "Tomorrow"); + + User user = createActiveUserWithAttendances("์ด์ง€ํ›ˆ", + List.of(yesterdayMeeting, tomorrowMeeting)); + + when(userGetService.find(userId)).thenReturn(user); + AttendanceDTO.Main mapped = mock(AttendanceDTO.Main.class); + when(attendanceMapper.toMainDto(user, null)).thenReturn(mapped); + + // when + AttendanceDTO.Main actual = attendanceUseCase.find(userId); + + // then + assertThat(actual).isSameAs(mapped); + verify(attendanceMapper).toMainDto(user, null); + } + + @Test + @DisplayName("findAllDetailsByCurrentCardinal: ํ˜„์žฌ ๊ธฐ์ˆ˜๋งŒ ํ•„ํ„ฐ๋งยท์ •๋ ฌํ•˜์—ฌ Detail ๋งคํ•‘") + void findAllDetailsByCurrentCardinal() { + // given + LocalDate today = LocalDate.now(); + Meeting meetingDayMinus1 = createOneDayMeeting(today.minusDays(1), 1, 1111, "D-1"); + Meeting meetingToday = createOneDayMeeting(today, 1, 2222, "D-Day"); + User user = createActiveUserWithAttendances("์ด์ง€ํ›ˆ", List.of(meetingDayMinus1, meetingToday)); + + List userAttendances = user.getAttendances(); + Attendance attendanceFirst = userAttendances.get(0); // D-1 + Attendance attendanceSecond = userAttendances.get(1); // D-Day + + when(userGetService.find(userId)).thenReturn(user); + Cardinal currentCardinal = mock(Cardinal.class); + when(currentCardinal.getCardinalNumber()).thenReturn(1); + when(userCardinalGetService.getCurrentCardinal(user)).thenReturn(currentCardinal); + + AttendanceDTO.Response responseFirst = mock(AttendanceDTO.Response.class); + AttendanceDTO.Response responseSecond = mock(AttendanceDTO.Response.class); + when(attendanceMapper.toResponseDto(attendanceFirst)).thenReturn(responseFirst); + when(attendanceMapper.toResponseDto(attendanceSecond)).thenReturn(responseSecond); + + AttendanceDTO.Detail expectedDetail = mock(AttendanceDTO.Detail.class); + when(attendanceMapper.toDetailDto(eq(user), anyList())).thenReturn(expectedDetail); + + // when + AttendanceDTO.Detail actualDetail = attendanceUseCase.findAllDetailsByCurrentCardinal(userId); + + // then + assertThat(actualDetail).isSameAs(expectedDetail); + verify(attendanceMapper).toDetailDto(eq(user), argThat(list -> list.size() == 2)); + } + + @Test + @DisplayName("close(now, cardinal): ๋‹น์ผ ์ •๊ธฐ๋ชจ์ž„์„ ์ฐพ์•„ close") + void close_success() { + // given + LocalDate now = LocalDate.now(); + Meeting targetMeeting = createOneDayMeeting(now, 1, 1111, "Today"); + Meeting otherMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday"); + + Attendance attendance1 = mock(Attendance.class); + Attendance attendance2 = mock(Attendance.class); + + when(meetingGetService.find(1)).thenReturn(List.of(targetMeeting, otherMeeting)); + when(attendanceGetService.findAllByMeeting(targetMeeting)).thenReturn(List.of(attendance1, attendance2)); + + // when + attendanceUseCase.close(now, 1); + + // then + verify(attendanceUpdateService).close(argThat(list -> + list.size() == 2 && list.containsAll(List.of(attendance1, attendance2)) + )); + } + + @Test + @DisplayName("close(now, cardinal): ๋‹น์ผ ์ •๊ธฐ๋ชจ์ž„์ด ์—†์œผ๋ฉด MeetingNotFoundException") + void close_notFound() { + // given + LocalDate now = LocalDate.now(); + Meeting otherDayMeeting = createOneDayMeeting(now.minusDays(1), 1, 9999, "Yesterday"); + + when(meetingGetService.find(1)).thenReturn(List.of(otherDayMeeting)); + + // when & then + assertThatThrownBy(() -> attendanceUseCase.close(now, 1)) + .isInstanceOf(MeetingNotFoundException.class); + } + + @Nested + @DisplayName("checkIn") + class CheckInTest { + + @Test + @DisplayName("์ง„ํ–‰ ์ค‘ ์ •๊ธฐ๋ชจ์ž„์ด๊ณ  ์ฝ”๋“œ ์ผ์น˜ํ•˜๋ฉฐ ์ƒํƒœ๊ฐ€ ATTEND๊ฐ€ ์•„๋‹ˆ๋ฉด ์ถœ์„ ์ฒ˜๋ฆฌ") + void checkIn_success() { + // given + User user = mock(User.class); + Meeting inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress"); + Attendance attendance = mock(Attendance.class); + when(attendance.getMeeting()).thenReturn(inProgressMeeting); + when(attendance.isWrong(1234)).thenReturn(false); + when(attendance.getStatus()).thenReturn(Status.PENDING); + + when(userGetService.find(userId)).thenReturn(user); + when(user.getAttendances()).thenReturn(List.of(attendance)); + + // when + attendanceUseCase.checkIn(userId, 1234); + + // then + verify(attendanceUpdateService).attend(attendance); + } + + @Test + @DisplayName("์ง„ํ–‰ ์ค‘ ์ •๊ธฐ๋ชจ์ž„์ด ์—†์œผ๋ฉด AttendanceNotFoundException") + void checkIn_notFoundMeeting() { + // given + User user = mock(User.class); + when(userGetService.find(userId)).thenReturn(user); + when(user.getAttendances()).thenReturn(List.of()); + + // when & then + assertThatThrownBy(() -> attendanceUseCase.checkIn(userId, 1234)) + .isInstanceOf(AttendanceNotFoundException.class); + } + + @Test + @DisplayName("์ฝ”๋“œ ๋ถˆ์ผ์น˜ ์‹œ AttendanceCodeMismatchException") + void checkIn_wrongCode() { + // given + User user = mock(User.class); + Meeting inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress"); + + Attendance attendance = mock(Attendance.class); + when(attendance.getMeeting()).thenReturn(inProgressMeeting); + when(attendance.isWrong(9999)).thenReturn(true); + + when(userGetService.find(userId)).thenReturn(user); + when(user.getAttendances()).thenReturn(List.of(attendance)); + + // when & then + assertThatThrownBy(() -> attendanceUseCase.checkIn(userId, 9999)) + .isInstanceOf(AttendanceCodeMismatchException.class); + } + + @Test + @DisplayName("์ด๋ฏธ ATTEND๋ฉด ์ถ”๊ฐ€ ์ฒ˜๋ฆฌ ์—†์ด ์ข…๋ฃŒ") + void checkIn_alreadyAttend() { + // given + User user = mock(User.class); + Meeting inProgressMeeting = createInProgressMeeting(1, 1234, "InProgress"); + + Attendance attendance = mock(Attendance.class); + when(attendance.getMeeting()).thenReturn(inProgressMeeting); + when(attendance.isWrong(1234)).thenReturn(false); + when(attendance.getStatus()).thenReturn(Status.ATTEND); + + when(userGetService.find(userId)).thenReturn(user); + when(user.getAttendances()).thenReturn(List.of(attendance)); + + // when + attendanceUseCase.checkIn(userId, 1234); + + // then + verify(attendanceUpdateService, never()).attend(any()); + } + } +} diff --git a/src/test/java/leets/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.java b/src/test/java/leets/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.java new file mode 100644 index 000000000..e41ab9462 --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/domain/repository/AttendanceRepositoryTest.java @@ -0,0 +1,85 @@ +package leets.weeth.domain.attendance.domain.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import leets.weeth.config.TestContainersConfig; +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.schedule.domain.entity.enums.MeetingStatus; +import leets.weeth.domain.schedule.domain.repository.MeetingRepository; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.enums.Status; +import leets.weeth.domain.user.domain.repository.UserRepository; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class AttendanceRepositoryTest { + + @Autowired private AttendanceRepository attendanceRepository; + @Autowired private MeetingRepository meetingRepository; + @Autowired private UserRepository userRepository; + + private Meeting meeting; + private User activeUser1; + private User activeUser2; + + @BeforeEach + void setUp() { + meeting = Meeting.builder() + .title("1์ฐจ ์ •๊ธฐ๋ชจ์ž„") + .start(LocalDateTime.now().minusHours(1)) + .end(LocalDateTime.now().plusHours(1)) + .code(1234) + .cardinal(1) + .meetingStatus(MeetingStatus.OPEN) + .build(); + meetingRepository.save(meeting); + + activeUser1 = User.builder().name("์ด์ง€ํ›ˆ").status(Status.ACTIVE).build(); + activeUser2 = User.builder().name("์ด๊ฐ•ํ˜").status(Status.ACTIVE).build(); + userRepository.saveAll(List.of(activeUser1, activeUser2)); + + attendanceRepository.save(new Attendance(meeting, activeUser1)); + attendanceRepository.save(new Attendance(meeting, activeUser2)); + } + + @Test + @DisplayName("ํŠน์ • ์ •๊ธฐ๋ชจ์ž„ + ์‚ฌ์šฉ์ž ์ƒํƒœ๋กœ ์ถœ์„ ๋ชฉ๋ก ์กฐํšŒ") + void findAllByMeetingAndUserStatus() { + // given + Status active = activeUser1.getStatus(); + + // when + List attendances = + attendanceRepository.findAllByMeetingAndUserStatus(meeting, active); + + // then + assertThat(attendances).hasSize(2); + assertThat(attendances) + .extracting(a -> a.getUser().getName()) + .containsExactlyInAnyOrder("์ด์ง€ํ›ˆ", "์ด๊ฐ•ํ˜"); + } + + @Test + @DisplayName("ํŠน์ • ์ •๊ธฐ๋ชจ์ž„์˜ ๋ชจ๋“  ์ถœ์„ ๋ ˆ์ฝ”๋“œ ์‚ญ์ œ") + void deleteAllByMeeting() { + // when + attendanceRepository.deleteAllByMeeting(meeting); + + // then + List after = attendanceRepository.findAll(); + assertThat(after).isEmpty(); + } +} diff --git a/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.java b/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.java new file mode 100644 index 000000000..b8b8c45e8 --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceSaveServiceTest.java @@ -0,0 +1,73 @@ +package leets.weeth.domain.attendance.domain.service; + +import static leets.weeth.domain.attendance.test.fixture.AttendanceTestFixture.*; +import static leets.weeth.domain.schedule.test.fixture.ScheduleTestFixture.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.attendance.domain.repository.AttendanceRepository; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.user.domain.entity.User; + +@ExtendWith(MockitoExtension.class) +class AttendanceSaveServiceTest { + + @Mock private AttendanceRepository attendanceRepository; + @InjectMocks private AttendanceSaveService attendanceSaveService; + + @Test + @DisplayName("init(user, meetings): ๊ฐ ์ •๊ธฐ๋ชจ์ž„์— ๋Œ€ํ•œ Attendance ์ €์žฅ ํ›„ user.add ํ˜ธ์ถœ") + void init_createsAttendanceAndLinkToUser() { + // given + User user = mock(User.class); + Meeting meetingFirst = createMeeting(); + Meeting meetingSecond = createMeeting(); + + when(attendanceRepository.save(any(Attendance.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + // when + attendanceSaveService.init(user, List.of(meetingFirst, meetingSecond)); + + // then + verify(attendanceRepository, times(2)).save(any(Attendance.class)); + verify(user, times(2)).add(any(Attendance.class)); + } + + @Test + @DisplayName("saveAll(users, meeting): ์‚ฌ์šฉ์ž ์ˆ˜๋งŒํผ Attendance ์ƒ์„ฑ ํ›„ saveAll ํ˜ธ์ถœ") + void saveAll_bulkInsert() { + // given + Meeting meeting = createMeeting(); + User userFirst = createActiveUser("์ด์ง€ํ›ˆ"); + User userSecond = createActiveUser("์ด๊ฐ•ํ˜"); + + // when + attendanceSaveService.saveAll(List.of(userFirst, userSecond), meeting); + + // then + @SuppressWarnings("unchecked") + ArgumentCaptor> listCaptor = ArgumentCaptor.forClass(List.class); + verify(attendanceRepository).saveAll(listCaptor.capture()); + + List savedAttendances = listCaptor.getValue(); + Assertions.assertThat(savedAttendances).hasSize(2); + + Assertions.assertThat(savedAttendances) + .allSatisfy(att -> Assertions.assertThat(att.getMeeting()).isSameAs(meeting)); + Assertions.assertThat(savedAttendances) + .extracting(Attendance::getUser) + .containsExactlyInAnyOrder(userFirst, userSecond); + } +} diff --git a/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.java b/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.java new file mode 100644 index 000000000..2682ac48a --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/domain/service/AttendanceUpdateServiceTest.java @@ -0,0 +1,107 @@ +package leets.weeth.domain.attendance.domain.service; + +import static leets.weeth.domain.attendance.test.fixture.AttendanceTestFixture.*; +import static leets.weeth.domain.schedule.test.fixture.ScheduleTestFixture.*; +import static org.mockito.Mockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.attendance.domain.entity.enums.Status; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.user.domain.entity.User; + +class AttendanceUpdateServiceTest { + + private final AttendanceUpdateService attendanceUpdateService = new AttendanceUpdateService(); + + @Test + @DisplayName("attend(): attendance.attend() + user.attend() ํ˜ธ์ถœ") + void attend_callsEntityMethods() { + // given + Meeting meeting = createMeeting(); + User realUser = createActiveUser("์ด์ง€ํ›ˆ"); + User userSpy = spy(realUser); + + doNothing().when(userSpy).attend(); + + Attendance realAttendance = createAttendance(meeting, userSpy); + Attendance attendanceSpy = spy(realAttendance); + + // when + attendanceUpdateService.attend(attendanceSpy); + + // then + verify(attendanceSpy).attend(); + verify(userSpy).attend(); + } + + @Test + @DisplayName("close(): pending๋งŒ close() + user.absent() ํ˜ธ์ถœ") + void close_onlyPendingIsClosed() { + // given + Meeting meeting = createMeeting(); + + User pendingUserReal = createActiveUser("pending-user"); + User nonPendingUserReal = createActiveUser("non-pending-user"); + User pendingUserSpy = spy(pendingUserReal); + User nonPendingUserSpy = spy(nonPendingUserReal); + + doNothing().when(pendingUserSpy).absent(); + doNothing().when(nonPendingUserSpy).absent(); + + Attendance pendingAttendanceReal = createAttendance(meeting, pendingUserSpy); + Attendance nonPendingAttendanceReal = createAttendance(meeting, nonPendingUserSpy); + Attendance pendingAttendanceSpy = spy(pendingAttendanceReal); + Attendance nonPendingAttendanceSpy = spy(nonPendingAttendanceReal); + + doReturn(true).when(pendingAttendanceSpy).isPending(); + doReturn(false).when(nonPendingAttendanceSpy).isPending(); + + // when + attendanceUpdateService.close(List.of(pendingAttendanceSpy, nonPendingAttendanceSpy)); + + // then + verify(pendingAttendanceSpy).close(); + verify(pendingUserSpy).absent(); + + verify(nonPendingAttendanceSpy, never()).close(); + verify(nonPendingUserSpy, never()).absent(); + } + + @Test + @DisplayName("updateUserAttendanceByStatus: ATTEND๋ฉด user.removeAttend(), ๊ทธ ์™ธ์—๋Š” user.removeAbsent()") + void updateUserAttendanceByStatus() { + // given + Meeting meeting = createMeeting(); + + User attendUserReal = createActiveUser("attend-user"); + User absentUserReal = createActiveUser("absent-user"); + + User attendUserSpy = spy(attendUserReal); + User absentUserSpy = spy(absentUserReal); + + doNothing().when(attendUserSpy).removeAttend(); + doNothing().when(absentUserSpy).removeAbsent(); + + Attendance attendAttendanceReal = createAttendance(meeting, attendUserSpy); + Attendance absentAttendanceReal = createAttendance(meeting, absentUserSpy); + Attendance attendAttendanceSpy = spy(attendAttendanceReal); + Attendance absentAttendanceSpy = spy(absentAttendanceReal); + + doReturn(Status.ATTEND).when(attendAttendanceSpy).getStatus(); + doReturn(Status.ABSENT).when(absentAttendanceSpy).getStatus(); + doReturn(attendUserSpy).when(attendAttendanceSpy).getUser(); + doReturn(absentUserSpy).when(absentAttendanceSpy).getUser(); + + // when + attendanceUpdateService.updateUserAttendanceByStatus(List.of(attendAttendanceSpy, absentAttendanceSpy)); + + // then + verify(attendUserSpy).removeAttend(); + verify(absentUserSpy).removeAbsent(); + } +} diff --git a/src/test/java/leets/weeth/domain/attendance/test/fixture/AttendanceTestFixture.java b/src/test/java/leets/weeth/domain/attendance/test/fixture/AttendanceTestFixture.java new file mode 100644 index 000000000..6cfe52b8b --- /dev/null +++ b/src/test/java/leets/weeth/domain/attendance/test/fixture/AttendanceTestFixture.java @@ -0,0 +1,101 @@ +package leets.weeth.domain.attendance.test.fixture; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import leets.weeth.domain.attendance.domain.entity.Attendance; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.enums.Department; +import leets.weeth.domain.user.domain.entity.enums.Position; +import leets.weeth.domain.user.domain.entity.enums.Status; + +public class AttendanceTestFixture { + + private AttendanceTestFixture() {} + + //todo : ์ถ”ํ›„ User Fixture ํ™œ์šฉ ์˜ˆ์ • + public static User createActiveUser(String name) { + return User.builder().name(name).status(Status.ACTIVE).build(); + } + + //todo : ์ถ”ํ›„ User Fixture ํ™œ์šฉ ์˜ˆ์ • + public static User createActiveUserWithAttendances(String name, List meetings) { + User user = createActiveUser(name); + + if (user.getAttendances() == null) { + try { + java.lang.reflect.Field f = user.getClass().getDeclaredField("attendances"); + f.setAccessible(true); + f.set(user, new java.util.ArrayList<>()); + } catch (Exception ignore) {} + } + if (meetings != null) { + for (Meeting meeting : meetings) { + Attendance attendance = createAttendance(meeting, user); + user.add(attendance); + } + } + return user; + } + + public static Attendance createAttendance(Meeting meeting, User user) { + return new Attendance(meeting, user); + } + + public static Meeting createOneDayMeeting(LocalDate date, int cardinal, int code, String title) { + return Meeting.builder() + .title(title) + .location("Test Location") + .start(date.atTime(10, 0)) + .end(date.atTime(12, 0)) + .code(code) + .cardinal(cardinal) + .build(); + } + + public static Meeting createInProgressMeeting(int cardinal, int code, String title) { + return Meeting.builder() + .title(title) + .location("Test Location") + .start(LocalDateTime.now().minusMinutes(5)) + .end(LocalDateTime.now().plusMinutes(5)) + .code(code) + .cardinal(cardinal) + .build(); + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static void setAttendanceId(Attendance attendance, Long id) { + setField(attendance, "id", id); + } + + public static void setUserAttendanceStats(User user, Integer attendanceCount, Integer absenceCount) { + setField(user, "attendanceCount", attendanceCount); + setField(user, "absenceCount", absenceCount); + } + + public static void enrichUserProfile(User user, Position position, Department department, String studentId) { + setField(user, "position", position); + setField(user, "department", department); + setField(user, "studentId", studentId); + } + + public static void enrichUserProfile(User user, Position position, String departmentKoreanValue, String studentId) { + setField(user, "position", position); + Department department = Department.to(departmentKoreanValue); // โ† ํ•ต์‹ฌ + setField(user, "department", department); + setField(user, "studentId", studentId); + } +} diff --git a/src/test/java/leets/weeth/domain/board/application/mapper/PostMapperTest.java b/src/test/java/leets/weeth/domain/board/application/mapper/PostMapperTest.java new file mode 100644 index 000000000..0f2f006c7 --- /dev/null +++ b/src/test/java/leets/weeth/domain/board/application/mapper/PostMapperTest.java @@ -0,0 +1,55 @@ +package leets.weeth.domain.board.application.mapper; + +import leets.weeth.domain.board.application.dto.PostDTO; +import leets.weeth.domain.board.domain.entity.Post; +import leets.weeth.domain.user.domain.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class PostMapperTest { + + @InjectMocks + private PostMapper mapper = Mappers.getMapper(PostMapper.class); + + private User testUser; + private Post testPost; + + @BeforeEach + void setUp() { + testUser = User.builder() + .id(1L) + .name("ํ…Œ์ŠคํŠธ์œ ์ €") + .email("test@weeth.com") + .build(); + + testPost = Post.builder() + .id(1L) + .title("ํ…Œ์ŠคํŠธ ๊ฒŒ์‹œ๊ธ€") + .user(testUser) + .content("ํ…Œ์ŠคํŠธ ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค.") + .build(); + } + + @Test + @DisplayName("Post๋ฅผ PostDTO.SaveResponse๋กœ ๋ณ€ํ™˜") + void toSaveResponse() { + // given + // testPost ์‚ฌ์šฉ + + // when + PostDTO.SaveResponse response = mapper.toSaveResponse(testPost); + + // then + assertThat(response).isNotNull(); + assertThat(response.id()).isEqualTo(testPost.getId()); + } + +} diff --git a/src/test/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.java b/src/test/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.java new file mode 100644 index 000000000..f03100a94 --- /dev/null +++ b/src/test/java/leets/weeth/domain/board/application/usecase/NoticeUsecaseImplTest.java @@ -0,0 +1,194 @@ +package leets.weeth.domain.board.application.usecase; + +import leets.weeth.domain.board.application.dto.NoticeDTO; +import leets.weeth.domain.board.application.mapper.NoticeMapper; +import leets.weeth.domain.board.domain.entity.Notice; +import leets.weeth.domain.board.domain.test.fixture.NoticeFixture; +import leets.weeth.domain.board.domain.service.NoticeFindService; +import leets.weeth.domain.file.domain.entity.File; +import leets.weeth.domain.file.domain.service.FileGetService; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.enums.Department; +import leets.weeth.domain.user.domain.entity.enums.Position; +import leets.weeth.domain.user.domain.entity.enums.Role; +import org.junit.jupiter.api.Disabled; +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; +import org.springframework.data.domain.*; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; + +import static org.mockito.BDDMockito.*; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@ExtendWith(MockitoExtension.class) +class NoticeUsecaseImplTest { + + @Mock + private NoticeFindService noticeFindService; + + @Mock + private FileGetService fileGetService; + + @InjectMocks + private NoticeUsecaseImpl noticeUsecase; + + @Mock + private NoticeMapper noticeMapper; + + @Test + void ๊ณต์ง€์‚ฌํ•ญ์ด_์ตœ์‹ ์ˆœ์œผ๋กœ_์ •๋ ฌ๋˜๋Š”์ง€() { + // given + User user = User.builder() + .email("abc@test.com") + .name("ํ™๊ธธ๋™") + .position(Position.BE) + .department(Department.SW) + .role(Role.USER) + .build(); + + List notices = new ArrayList<>(); + for(int i = 0; i<5; i++){ + Notice notice = NoticeFixture.createNotice("๊ณต์ง€" + i, user); + ReflectionTestUtils.setField(notice, "id", (long) i + 1); + notices.add(notice); + } + + Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")); + + Slice slice = new SliceImpl<>(List.of(notices.get(4), notices.get(3), notices.get(2)), pageable, true); + + given(noticeFindService.findRecentNotices(any(Pageable.class))).willReturn(slice); + given(fileGetService.findAllByNotice(any())).willReturn(List.of()); + + given(noticeMapper.toAll(any(Notice.class), anyBoolean())) + .willAnswer(invocation -> { + Notice notice = invocation.getArgument(0); + return new NoticeDTO.ResponseAll( + notice.getId(), + notice.getUser() != null ? notice.getUser().getName() : "", + notice.getUser() != null ? notice.getUser().getPosition() : Position.BE, + notice.getUser() != null ? notice.getUser().getRole() : Role.USER, + notice.getTitle(), + notice.getContent(), + notice.getCreatedAt(), + notice.getCommentCount(), + false + ); + }); + + // when + Slice noticeResponses = noticeUsecase.findNotices(0, 3); + + // then + assertThat(noticeResponses).isNotNull(); + assertThat(noticeResponses.getContent()).hasSize(3); + assertThat(noticeResponses.getContent()) + .extracting(NoticeDTO.ResponseAll::title) + .containsExactly( + notices.get(4).getTitle(), + notices.get(3).getTitle(), + notices.get(2).getTitle() + ); + assertThat(noticeResponses.hasNext()).isTrue(); + + verify(noticeFindService, times(1)).findRecentNotices(pageable); + + } + + @Test + void ๊ณต์ง€์‚ฌํ•ญ_๊ฒ€์ƒ‰์‹œ_๊ฒฐ๊ณผ์™€_ํŒŒ์ผ_์กด์žฌ์—ฌ๋ถ€๊ฐ€_์ •์ƒ์ ์œผ๋กœ_๋ฐ˜ํ™˜() { + // given + User user = User.builder() + .email("abc@test.com") + .name("ํ™๊ธธ๋™") + .position(Position.BE) + .department(Department.SW) + .role(Role.USER) + .build(); + + List notices = new ArrayList<>(); + for(int i = 0; i<3; i++){ + Notice notice = NoticeFixture.createNotice("๊ณต์ง€" + i, user); + ReflectionTestUtils.setField(notice, "id", (long) i + 1); + notices.add(notice); + } + for(int i = 3; i<6; i++){ + Notice notice = NoticeFixture.createNotice("๊ฒ€์ƒ‰" + i, user); + ReflectionTestUtils.setField(notice, "id", (long) i + 1); + notices.add(notice); + } + + + Pageable pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")); + + Slice slice = new SliceImpl<>(List.of(notices.get(5), notices.get(4), notices.get(3)), pageable, false); + + given(noticeFindService.search(any(String.class), any(Pageable.class))).willReturn(slice); + // ์ง์ˆ˜ id - ํŒŒ์ผ ์กด์žฌ, ํ™€์ˆ˜ id - ํŒŒ์ผ ์—†์Œ (๋นˆ ๋ฆฌ์ŠคํŠธ) + given(fileGetService.findAllByNotice(any())) + .willAnswer(invocation -> { + Long noticeId = invocation.getArgument(0); + if (noticeId % 2 == 0) { + return List.of(File.builder() + .notice(notices.get((int)(noticeId-1))) + .build()); + } else { + return List.of(); + } + }); + + given(noticeMapper.toAll(any(Notice.class), anyBoolean())) + .willAnswer(invocation -> { + Notice notice = invocation.getArgument(0); + boolean fileExists = invocation.getArgument(1); + return new NoticeDTO.ResponseAll( + notice.getId(), + notice.getUser() != null ? notice.getUser().getName() : "", + notice.getUser() != null ? notice.getUser().getPosition() : Position.BE, + notice.getUser() != null ? notice.getUser().getRole() : Role.USER, + notice.getTitle(), + notice.getContent(), + notice.getCreatedAt(), + notice.getCommentCount(), + fileExists + ); + }); + + // when + Slice noticeResponses = noticeUsecase.searchNotice("๊ฒ€์ƒ‰", 0, 5); + + // then + assertThat(noticeResponses).isNotNull(); + assertThat(noticeResponses.getContent()).hasSize(3); + assertThat(noticeResponses.getContent()) + .extracting(NoticeDTO.ResponseAll::title) + .containsExactly( + notices.get(5).getTitle(), + notices.get(4).getTitle(), + notices.get(3).getTitle() + ); + assertThat(noticeResponses.hasNext()).isFalse(); + + // ์ง์ˆ˜ id : ํŒŒ์ผ ์กด์žฌ, ํ™€์ˆ˜ id : ํŒŒ์ผ ์—†์Œ ๊ฒ€์ฆ + assertThat(noticeResponses.getContent().get(0).hasFile()).isTrue(); + assertThat(noticeResponses.getContent().get(1).hasFile()).isFalse(); + + verify(noticeFindService, times(1)).search("๊ฒ€์ƒ‰", pageable); + } + + @Disabled("TODO: update ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ๊ตฌํ˜„ ํ•„์š”") + @Test + void update() { + } + + @Disabled("TODO: delete ๊ธฐ๋Šฅ ํ…Œ์ŠคํŠธ ๊ตฌํ˜„ ํ•„์š”") + @Test + void delete() { + } +} diff --git a/src/test/java/leets/weeth/domain/board/domain/repository/NoticeRepositoryTest.java b/src/test/java/leets/weeth/domain/board/domain/repository/NoticeRepositoryTest.java new file mode 100644 index 000000000..2c017a487 --- /dev/null +++ b/src/test/java/leets/weeth/domain/board/domain/repository/NoticeRepositoryTest.java @@ -0,0 +1,87 @@ +package leets.weeth.domain.board.domain.repository; + +import leets.weeth.config.TestContainersConfig; +import leets.weeth.domain.board.domain.entity.Notice; +import leets.weeth.domain.board.domain.test.fixture.NoticeFixture; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class NoticeRepositoryTest { + + @Autowired + private NoticeRepository noticeRepository; + + @Test + void findPageBy_๊ณต์ง€_id_๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ_์กฐํšŒ() { + // given + List notices = new ArrayList<>(); + for(int i = 0; i<5; i++){ + Notice notice = NoticeFixture.createNotice("๊ณต์ง€" + i); + notices.add(notice); + } + + noticeRepository.saveAll(notices); + Pageable pageable = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "id")); + + // when + Slice pagedNotices = noticeRepository.findPageBy(pageable); + + // then + assertThat(pagedNotices.getSize()).isEqualTo(3); + assertThat(pagedNotices) + .extracting(Notice::getTitle) + .containsExactly( + notices.get(4).getTitle(), + notices.get(3).getTitle(), + notices.get(2).getTitle() + ); + assertThat(pagedNotices.hasNext()).isTrue(); + } + + @Test + void search_๊ฒ€์ƒ‰์–ด๊ฐ€_ํฌํ•จ๋œ_๊ณต์ง€_id_๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ_์กฐํšŒ() { + // given + List notices = new ArrayList<>(); + for(int i = 0; i<6; i++){ + Notice notice; + if(i % 2 == 0){ + notice = NoticeFixture.createNotice("๊ณต์ง€" + i); + } else{ + notice = NoticeFixture.createNotice("๊ฒ€์ƒ‰" + i); + } + notices.add(notice); + } + + noticeRepository.saveAll(notices); + + Pageable pageable = PageRequest.of(0, 5, Sort.by(Sort.Direction.DESC, "id")); + + // when + Slice searchedNotices = noticeRepository.search("๊ฒ€์ƒ‰", pageable); + + // then + assertThat(searchedNotices.getContent()).hasSize(3); + assertThat(searchedNotices.getContent()) + .extracting(Notice::getTitle) + .containsExactly(notices.get(5).getTitle(), + notices.get(3).getTitle(), + notices.get(1).getTitle()); + assertThat(searchedNotices.hasNext()).isFalse(); + + } +} diff --git a/src/test/java/leets/weeth/domain/board/domain/test/fixture/NoticeFixture.java b/src/test/java/leets/weeth/domain/board/domain/test/fixture/NoticeFixture.java new file mode 100644 index 000000000..5bd9e00e4 --- /dev/null +++ b/src/test/java/leets/weeth/domain/board/domain/test/fixture/NoticeFixture.java @@ -0,0 +1,23 @@ +package leets.weeth.domain.board.domain.test.fixture; + +import leets.weeth.domain.board.domain.entity.Notice; +import leets.weeth.domain.user.domain.entity.User; + +public class NoticeFixture { + public static Notice createNotice(String title, User user){ + return Notice.builder() + .title(title) + .content("๋‚ด์šฉ") + .user(user) + .commentCount(0) + .build(); + } + + public static Notice createNotice(String title){ + return Notice.builder() + .title(title) + .content("๋‚ด์šฉ") + .commentCount(0) + .build(); + } +} diff --git a/src/test/java/leets/weeth/domain/schedule/test/fixture/ScheduleTestFixture.java b/src/test/java/leets/weeth/domain/schedule/test/fixture/ScheduleTestFixture.java new file mode 100644 index 000000000..92fcf7b3a --- /dev/null +++ b/src/test/java/leets/weeth/domain/schedule/test/fixture/ScheduleTestFixture.java @@ -0,0 +1,30 @@ +package leets.weeth.domain.schedule.test.fixture; + +import java.time.LocalDateTime; + +import leets.weeth.domain.schedule.domain.entity.Event; +import leets.weeth.domain.schedule.domain.entity.Meeting; + +public class ScheduleTestFixture { + + public static Event createEvent() { + return Event.builder() + .title("Test Meeting") + .location("Test Location") + .start(LocalDateTime.now()) + .end(LocalDateTime.now().plusDays(2)) + .cardinal(1) + .build(); + } + + public static Meeting createMeeting() { + return Meeting.builder() + .title("Test Meeting") + .location("Test Location") + .start(LocalDateTime.now()) + .end(LocalDateTime.now().plusDays(2)) + .code(1234) + .cardinal(1) + .build(); + } +} diff --git a/src/test/java/leets/weeth/domain/user/application/usecase/CardinalUseCaseTest.java b/src/test/java/leets/weeth/domain/user/application/usecase/CardinalUseCaseTest.java new file mode 100644 index 000000000..316de92c4 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/application/usecase/CardinalUseCaseTest.java @@ -0,0 +1,155 @@ +package leets.weeth.domain.user.application.usecase; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.assertj.core.api.InstanceOfAssertFactories.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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; + +import leets.weeth.domain.user.application.dto.request.CardinalSaveRequest; +import leets.weeth.domain.user.application.dto.request.CardinalUpdateRequest; +import leets.weeth.domain.user.application.dto.response.CardinalResponse; +import leets.weeth.domain.user.application.mapper.CardinalMapper; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.enums.CardinalStatus; +import leets.weeth.domain.user.domain.service.CardinalGetService; +import leets.weeth.domain.user.domain.service.CardinalSaveService; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; + +@ExtendWith(MockitoExtension.class) +public class CardinalUseCaseTest { + // ์‹ค์ œ CardinalUseCase์—์„œ ์‚ฌ์šฉํ•˜๋Š” ์˜์กด์„ฑ์„ Mock ๊ฐ์ฒด๋กœ ๋Œ€์‹  ์ฃผ์ž… + @Mock + private CardinalGetService cardinalGetService; + + @Mock + private CardinalSaveService cardinalSaveService; + + @Mock + private CardinalMapper cardinalMapper; + + @InjectMocks + private CardinalUseCase useCase; + + // Given-When-Then ํŒจํ„ด์„ ์‰ฝ๊ฒŒ ์ดํ•ดํ•˜๊ธฐ ์œ„ํ•ด ๋ฉ”์„œ๋“œ๋ช…_์ƒํ™ฉ_์˜ˆ์ƒ๊ฒฐ๊ณผ๋กœ ํ…Œ์ŠคํŠธ ๋ฉ”์„œ๋“œ ๋„ค์ด๋ฐ + + + @Test // ์ง„ํ–‰์ค‘์ด ์•„๋‹Œ ๊ธฐ์ˆ˜๋ฅผ ๋“ฑ๋กํ•˜๋Š” ๊ฒฝ์šฐ + void save_์ง„ํ–‰์ค‘์ด_์•„๋‹Œ_๊ธฐ์ˆ˜๋ผ๋ฉด_๊ฒ€์ฆํ›„_์ €์žฅ๋งŒ() { + //given + var request = new CardinalSaveRequest(7, 2025,1,false); + + var toSave = CardinalTestFixture.createCardinal(7, 2025, 1); + var saved = CardinalTestFixture.createCardinal(7,2025 ,1); + + willDoNothing().given(cardinalGetService).validateCardinal(7); + given(cardinalMapper.from(request)).willReturn(toSave); + given(cardinalSaveService.save(toSave)).willReturn(saved); + + //when + useCase.save(request); + + //then + then(cardinalGetService).should().validateCardinal(7); + then(cardinalSaveService).should().save(toSave); + then(cardinalGetService).should(never()).findInProgress(); + } + + @Test + void save_์ƒˆ_๊ธฐ์ˆ˜๊ฐ€_์ง„ํ–‰์ค‘์ด๋ผ๋ฉด_๊ธฐ์กด_๊ธฐ์ˆ˜๋Š”_done_ํ˜„์žฌ๊ธฐ์ˆ˜๋Š”_inProgress() { + // given + var request = new CardinalSaveRequest(7, 2025,1,true); + + var oldCardinal = CardinalTestFixture.createCardinalInProgress(6, 2024, 2); + var newCardinalBeforeSave = CardinalTestFixture.createCardinal(7, 2025, 1); + var newCardinalAfterSave = CardinalTestFixture.createCardinal(7, 2025, 1); + + given(cardinalGetService.findInProgress()).willReturn(List.of(oldCardinal)); + given(cardinalMapper.from(request)).willReturn(newCardinalBeforeSave); + given(cardinalSaveService.save(newCardinalBeforeSave)).willReturn(newCardinalAfterSave); + + // when + useCase.save(request); + + // then + then(cardinalGetService).should().findInProgress(); + then(cardinalSaveService).should().save(newCardinalBeforeSave); + + assertThat(oldCardinal.getStatus()).isEqualTo(CardinalStatus.DONE); + assertThat(newCardinalAfterSave.getStatus()).isEqualTo(CardinalStatus.IN_PROGRESS); + } + + + @Test + void update_์—ฐ๋„์™€_ํ•™๊ธฐ๋ฅผ_๋ณ€๊ฒฝํ•œ๋‹ค() { + //given + var cardinal = CardinalTestFixture.createCardinal(6, 2024, 2); + var dto = new CardinalUpdateRequest(1L, 2025,1,false); + + //when + cardinal.update(dto); + + //then + assertThat(cardinal.getYear()).isEqualTo(2025); + assertThat(cardinal.getSemester()).isEqualTo(1); + } + + + @Test + void findAll_์กฐํšŒ๋œ_๋ชจ๋“ _๊ธฐ์ˆ˜๋ฅผ_DTO๋กœ_๋งคํ•‘์ฒ˜๋ฆฌ() { + + //given + var cardinal1 = CardinalTestFixture.createCardinal(1L,6,2024,2); + var cardinal2 = CardinalTestFixture.createCardinalInProgress(2L,7,2025,1); + var cardinals = List.of(cardinal1, cardinal2); + var now = LocalDateTime.now(); + + var response1 = new CardinalResponse( + 1L, 6, 2024, 2, + CardinalStatus.DONE, + now.minusDays(5), + now.minusDays(3) + ); + + var response2 = new CardinalResponse( + 2L, 7, 2025, 1, + CardinalStatus.IN_PROGRESS, + now.minusDays(2), + now + ); + + given(cardinalGetService.findAll()).willReturn(cardinals); + given(cardinalMapper.to(cardinal1)).willReturn(response1); + given(cardinalMapper.to(cardinal2)).willReturn(response2); + + //when + List responses = useCase.findAll(); + + + //then + then(cardinalGetService).should().findAll(); + then(cardinalMapper).should(times(2)).to(any(Cardinal.class)); + + assertThat(responses) + .asInstanceOf(list(CardinalResponse.class)) + .hasSize(2) + .extracting(CardinalResponse::cardinalNumber) + .containsExactly(6, 7); + + assertThat(responses) + .asInstanceOf(list(CardinalResponse.class)) + .extracting(CardinalResponse::status) + .containsExactly(CardinalStatus.DONE, CardinalStatus.IN_PROGRESS); + + assertThat(responses.get(0).createdAt()).isBefore(responses.get(1).createdAt()); + } + +} diff --git a/src/test/java/leets/weeth/domain/user/application/usecase/UserManageUseCaseTest.java b/src/test/java/leets/weeth/domain/user/application/usecase/UserManageUseCaseTest.java new file mode 100644 index 000000000..0ab1e56de --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/application/usecase/UserManageUseCaseTest.java @@ -0,0 +1,230 @@ +package leets.weeth.domain.user.application.usecase; + + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +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; +import org.springframework.security.crypto.password.PasswordEncoder; + +import leets.weeth.domain.attendance.domain.service.AttendanceSaveService; +import leets.weeth.domain.schedule.domain.entity.Meeting; +import leets.weeth.domain.schedule.domain.service.MeetingGetService; +import leets.weeth.domain.user.application.dto.request.UserRequestDto; +import leets.weeth.domain.user.application.dto.response.UserResponseDto; +import leets.weeth.domain.user.application.exception.InvalidUserOrderException; +import leets.weeth.domain.user.application.mapper.UserMapper; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.UserCardinal; +import leets.weeth.domain.user.domain.entity.enums.Role; +import leets.weeth.domain.user.domain.entity.enums.Status; +import leets.weeth.domain.user.domain.entity.enums.UsersOrderBy; +import leets.weeth.domain.user.domain.service.CardinalGetService; +import leets.weeth.domain.user.domain.service.UserCardinalGetService; +import leets.weeth.domain.user.domain.service.UserCardinalSaveService; +import leets.weeth.domain.user.domain.service.UserDeleteService; +import leets.weeth.domain.user.domain.service.UserGetService; +import leets.weeth.domain.user.domain.service.UserUpdateService; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; +import leets.weeth.domain.user.test.fixture.UserTestFixture; +import leets.weeth.global.auth.jwt.service.JwtRedisService; + +@ExtendWith(MockitoExtension.class) +public class UserManageUseCaseTest { + + @Mock private UserGetService userGetService; + @Mock private UserUpdateService userUpdateService; + @Mock private UserDeleteService userDeleteService; + + @Mock private AttendanceSaveService attendanceSaveService; + @Mock private MeetingGetService meetingGetService; + @Mock private JwtRedisService jwtRedisService; + @Mock private CardinalGetService cardinalGetService; + @Mock private UserCardinalSaveService userCardinalSaveService; + @Mock private UserCardinalGetService userCardinalGetService; + + @Mock private UserMapper userMapper; + @Mock private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserManageUseCaseImpl useCase; + + + @Test + void findAllByAdmin_orderBy๊ฐ€_null์ด๋ฉด_์˜ˆ์™ธ๊ฐ€์ •์ƒ๋ฐœ์ƒํ•˜๋Š”์ง€(){ + //given + UsersOrderBy orderBy = null; + + //when & then + assertThatThrownBy(() -> useCase.findAllByAdmin(orderBy)) + .isInstanceOf(InvalidUserOrderException.class); + + } + + @Test + void findAllByAdmin์ด_orderBy์—_๋งž๊ฒŒ_์ •๋ ฌ๋˜์–ด_์กฐํšŒ๋˜๋Š”์ง€() { + //given + UsersOrderBy orderBy = UsersOrderBy.NAME_ASCENDING; + + var user1 = UserTestFixture.createActiveUser1(); + var user2 = UserTestFixture.createWaitingUser2(); + var cd1 = CardinalTestFixture.createCardinal(1L,6,2020,2); + var cd2 = CardinalTestFixture.createCardinal(2L,7,2021,1); + var uc1 = new UserCardinal(user1, cd1); + var uc2 = new UserCardinal(user2, cd2); + + var adminResponse1 = new UserResponseDto.AdminResponse( + 1, "aaa", "a@a.com", "202034420", "01011112222", "์‚ฐ์—…๊ณตํ•™๊ณผ", + List.of(6), null, Status.ACTIVE, null, + 0, 0, 0, 0, 0, + LocalDateTime.now().minusDays(3), + LocalDateTime.now() + ); + + var adminResponse2 = new UserResponseDto.AdminResponse( + 2, "bbb", "b@b.com", "202045678", "01033334444", "์ปดํ“จํ„ฐ๊ณตํ•™๊ณผ", + List.of(7), null, Status.WAITING, null, + 0, 0, 0, 0, 0, + LocalDateTime.now().minusDays(2), + LocalDateTime.now() + ); + + given(userCardinalGetService.getUserCardinals(user1)).willReturn(List.of(uc1)); + given(userCardinalGetService.getUserCardinals(user2)).willReturn(List.of(uc2)); + given(userCardinalGetService.findAll()).willReturn(List.of(uc2, uc1)); + given(userMapper.toAdminResponse(user1, List.of(uc1))).willReturn(adminResponse1); + given(userMapper.toAdminResponse(user2, List.of(uc2))).willReturn(adminResponse2); + + + //when + var result = useCase.findAllByAdmin(UsersOrderBy.NAME_ASCENDING); + + + //then + assertThat(result).hasSize(2); + assertThat(result.get(0).name()).isEqualTo("aaa"); + assertThat(result.get(1).name()).isEqualTo("bbb"); + + } + + + @Test + void accept_๋น„ํ™œ์„ฑ์œ ์ €_์Šน์ธ์‹œ_์ถœ์„์ดˆ๊ธฐํ™”_์ •์ƒํ˜ธ์ถœ๋˜๋Š”์ง€() { + //given + var user1 = UserTestFixture.createWaitingUser1(1L); + var userIds = new UserRequestDto.UserId(List.of(1L)); + var cardinal = CardinalTestFixture.createCardinal(1L,8,2020,2); + var meetings = List.of(mock(Meeting.class)); + + given(userGetService.findAll(userIds.userId())).willReturn(List.of(user1)); + given(userCardinalGetService.getCurrentCardinal(user1)).willReturn(cardinal); + given(meetingGetService.find(8)).willReturn(meetings); + + //when + useCase.accept(userIds); + + //then + then(userUpdateService).should().accept(user1); + then(attendanceSaveService).should().init(user1,meetings); + + } + + @Test + void update_์œ ์ €๊ถŒํ•œ๋ณ€๊ฒฝ์‹œ_DB์™€_Redis_๋ชจ๋‘๊ฐฑ์‹ ๋˜๋Š”์ง€() { + // given + var user1 = UserTestFixture.createActiveUser1(1L); + var request = new UserRequestDto.UserRoleUpdate(1L, Role.ADMIN); + + lenient().when(userGetService.find((Long)1L)).thenReturn(user1); + + // when + useCase.update(List.of(request)); + + // then + then(userUpdateService).should().update(user1, "ADMIN"); + then(jwtRedisService).should().updateRole(1L, "ADMIN"); + } + + @Test + void leave_ํšŒ์›ํƒˆํ‡ด์‹œ_ํ† ํฐ๋ฌดํšจํ™”_๋ฐ_์œ ์ €์ƒํƒœ๋ณ€๊ฒฝ๋˜๋Š”์ง€() { + //given + var user1 = UserTestFixture.createActiveUser1(1L); + given(userGetService.find((Long)1L)).willReturn(user1); + + //when + useCase.leave(1L); + + //then + then(jwtRedisService).should().delete(1L); + then(userDeleteService).should().leave(user1); + + } + + @Test + void ban_ํšŒ์›ban์‹œ_ํ† ํฐ๋ฌดํšจํ™”_๋ฐ_์œ ์ €์ƒํƒœ๋ณ€๊ฒฝ๋˜๋Š”์ง€() { + //given + var user1 = UserTestFixture.createActiveUser1(1L); + var ids = new UserRequestDto.UserId(List.of(1L)); + given(userGetService.findAll(ids.userId())).willReturn(List.of(user1)); + + //when + useCase.ban(ids); + + //then + then(jwtRedisService).should().delete(1L); + then(userDeleteService).should().ban(user1); + + } + + @Test + void applyOB_ํ˜„์žฌ๊ธฐ์ˆ˜_OB์‹ ์ฒญ์‹œ_์ถœ์„์ดˆ๊ธฐํ™”_๋ฐ_๊ธฐ์ˆ˜์—…๋ฐ์ดํŠธ() { + //given + var user = User.builder().id(1L).name("aaa").status(Status.ACTIVE).attendances(new ArrayList<>()).build(); + var nextCardinal = CardinalTestFixture.createCardinal(1L,4,2020,2); + var request = new UserRequestDto.UserApplyOB(1L,4); + var meeting = List.of(mock(Meeting.class)); + + given(userGetService.find((Long)1L)).willReturn(user); + given(cardinalGetService.findByAdminSide(4)).willReturn(nextCardinal); + given(userCardinalGetService.notContains(user, nextCardinal)).willReturn(true); + given(userCardinalGetService.isCurrent(user, nextCardinal)).willReturn(true); + given(meetingGetService.find(4)).willReturn(meeting); + + //when + useCase.applyOB(List.of(request)); + + //then + then(attendanceSaveService).should().init(user,meeting); + then(userCardinalSaveService).should().save(any(UserCardinal.class)); + } + + @Test + void reset_๋น„๋ฐ€๋ฒˆํ˜ธ์ดˆ๊ธฐํ™”์‹œ_๋ชจ๋“ ์œ ์ €์—_resetํ˜ธ์ถœ๋˜๋Š”์ง€() { + // given + var user1 = UserTestFixture.createActiveUser1(1L); + var user2 = UserTestFixture.createActiveUser2(2L); + var ids = new UserRequestDto.UserId(List.of(1L, 2L)); + + given(userGetService.findAll(ids.userId())).willReturn(List.of(user1, user2)); + + // when + useCase.reset(ids); + + // then + then(userGetService).should().findAll(ids.userId()); + then(userUpdateService).should().reset(user1, passwordEncoder); + then(userUpdateService).should().reset(user2, passwordEncoder); + } + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/repository/CardinalRepositoryTest.java b/src/test/java/leets/weeth/domain/user/domain/repository/CardinalRepositoryTest.java new file mode 100644 index 000000000..b773712dd --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/repository/CardinalRepositoryTest.java @@ -0,0 +1,38 @@ +package leets.weeth.domain.user.domain.repository; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import leets.weeth.config.TestContainersConfig; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class CardinalRepositoryTest { + + @Autowired + CardinalRepository cardinalRepository; + + @Test + void ๊ธฐ์ˆ˜๋ฒˆํ˜ธ๋กœ_์กฐํšŒ๋˜๋Š”์ง€() { + //given + var cardinal = CardinalTestFixture.createCardinal(7,2025,1); + cardinalRepository.save(cardinal); + + //when + var result = cardinalRepository.findByCardinalNumber(7); + + //then + assertThat(result).isPresent(); + assertThat(result.get().getYear()).isEqualTo(2025); + } + + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.java b/src/test/java/leets/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.java new file mode 100644 index 000000000..34a9bafd5 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/repository/UserCardinalRepositoryTest.java @@ -0,0 +1,99 @@ +package leets.weeth.domain.user.domain.repository; + +import static org.assertj.core.api.Assertions.assertThat; + + +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import leets.weeth.config.TestContainersConfig; +import leets.weeth.domain.user.domain.entity.UserCardinal; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; +import leets.weeth.domain.user.test.fixture.UserTestFixture; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class UserCardinalRepositoryTest { + + @Autowired + UserRepository userRepository; + + @Autowired + CardinalRepository cardinalRepository; + + @Autowired + UserCardinalRepository userCardinalRepository; + + @Test + void ์œ ์ €๋ณ„_๊ธฐ์ˆ˜_๋‚ด๋ฆผ์ฐจ์ˆœ_์กฐํšŒ๋˜๋Š”์ง€() { + //given + var user = UserTestFixture.createActiveUser1(); + + userRepository.save(user); + + var cardinal1 = cardinalRepository.save(CardinalTestFixture.createCardinal(5,2023,1)); + var cardinal2 = cardinalRepository.save(CardinalTestFixture.createCardinal(6,2023,2)); + var cardinal3 = cardinalRepository.save(CardinalTestFixture.createCardinal(7,2024,1)); + + userCardinalRepository.saveAll(List.of( + new UserCardinal(user, cardinal1), + new UserCardinal(user, cardinal2), + new UserCardinal(user, cardinal3) + )); + + //when + List result = userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user); + + //then + assertThat(result).hasSize(3); + assertThat(result.get(0).getCardinal().getCardinalNumber()).isEqualTo(7); + assertThat(result.get(1).getCardinal().getCardinalNumber()).isEqualTo(6); + assertThat(result.get(2).getCardinal().getCardinalNumber()).isEqualTo(5); + + } + + @Test + void ์—ฌ๋Ÿฌ_์œ ์ €์˜_๊ธฐ์ˆ˜๋ฅผ_์œ ์ €๋ณ„_๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ_์กฐํšŒํ•œ๋‹ค() { + //given + var user1 = UserTestFixture.createActiveUser1(); + var user2 = UserTestFixture.createActiveUser2(); + + userRepository.save(user1); + userRepository.save(user2); + + var c1 = cardinalRepository.save(CardinalTestFixture.createCardinal(5,2023,1)); + var c2 = cardinalRepository.save(CardinalTestFixture.createCardinal(6,2023,2)); + var c3 = cardinalRepository.save(CardinalTestFixture.createCardinal(7,2024,1)); + var c4 = cardinalRepository.save(CardinalTestFixture.createCardinal(8,2024,2)); + + userCardinalRepository.saveAll(List.of( + new UserCardinal(user1, c3), + new UserCardinal(user1, c2) + )); + userCardinalRepository.saveAll(List.of( + new UserCardinal(user2, c4), + new UserCardinal(user2, c1) + )); + + //when + List result = userCardinalRepository.findAllByUsers(List.of(user1, user2)); + + //then + assertThat(result).hasSize(4); + assertThat(result.get(0).getUser().getId()).isEqualTo(user1.getId()); + assertThat(result.get(0).getCardinal().getCardinalNumber()).isEqualTo(7); + assertThat(result.get(1).getCardinal().getCardinalNumber()).isEqualTo(6); + + assertThat(result.get(2).getUser().getId()).isEqualTo(user2.getId()); + assertThat(result.get(2).getCardinal().getCardinalNumber()).isEqualTo(8); + assertThat(result.get(3).getCardinal().getCardinalNumber()).isEqualTo(5); + } + + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/repository/UserRepositoryTest.java b/src/test/java/leets/weeth/domain/user/domain/repository/UserRepositoryTest.java new file mode 100644 index 000000000..110efc5f6 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/repository/UserRepositoryTest.java @@ -0,0 +1,111 @@ +package leets.weeth.domain.user.domain.repository; + +import static org.assertj.core.api.AssertionsForInterfaceTypes.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import leets.weeth.config.TestContainersConfig; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.UserCardinal; +import leets.weeth.domain.user.domain.entity.enums.Status; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; +import leets.weeth.domain.user.test.fixture.UserCardinalTestFixture; +import leets.weeth.domain.user.test.fixture.UserTestFixture; + +@DataJpaTest +@Import(TestContainersConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserCardinalRepository userCardinalRepository; + + @Autowired + private CardinalRepository cardinalRepository; + + private Cardinal cardinal7; + private Cardinal cardinal8; + + @BeforeEach + void setUp() { + + cardinal7 = cardinalRepository.save(CardinalTestFixture.createCardinal(7, 2026, 1)); + cardinal8 = cardinalRepository.save(CardinalTestFixture.createCardinal(8, 2026, 2)); + + var user1 = userRepository.save(UserTestFixture.createActiveUser1()); + var user2 = userRepository.save(UserTestFixture.createActiveUser2()); + var user3 = userRepository.save(UserTestFixture.createWaitingUser1()); + + user1.accept(); + user2.accept(); + userCardinalRepository.flush(); + + userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user1, cardinal7)); + userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user2, cardinal8)); + userCardinalRepository.save(UserCardinalTestFixture.linkUserCardinal(user3, cardinal7)); + + } + + @Test + @DisplayName("findAllByCardinalAndStatus(): ํŠน์ • ๊ธฐ์ˆ˜ + ์ƒํƒœ์— ๋งž๋Š” ์œ ์ €๋งŒ ์กฐํšŒ๋œ๋‹ค") + void findAllByCardinalAndStatus() { + // when + List result = userRepository.findAllByCardinalAndStatus(cardinal7, Status.ACTIVE); + + // then + assertThat(result) + .hasSize(1) + .extracting(User::getName) + .containsExactly("์ ์ˆœ"); + } + + @Test + @DisplayName("findAllByStatusOrderByCardinalAndName() : ์ƒํƒœ๋ณ„๋กœ ์ตœ์‹  ๊ธฐ์ˆ˜์ˆœ + ์ด๋ฆ„ ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌ๋œ๋‹ค") + void findAllByStatusOrderByCardinalAndName() { + //given + Pageable pageable = PageRequest.of(0,10); + + //when + Slice resultSlice = userRepository.findAllByStatusOrderedByCardinalAndName(Status.ACTIVE, pageable); + List result = resultSlice.getContent(); + + //then + assertThat(result) + .hasSize(2) + .extracting(User::getName) + .containsExactly("์ ์ˆœ2", "์ ์ˆœ"); + } + + @Test + @DisplayName("findAllByCardinalOrderByNameAsc() : Active์ธ ์œ ์ €๋“ค ์ค‘ ํŠน์ • ๊ธฐ์ˆ˜ + ์ด๋ฆ„ ์˜ค๋ฆ„์ฐจ์ˆœ์œผ๋กœ ์ •๋ ฌํ•œ๋‹ค.") + void findAllByCardinalOrderByNameAsc() { + //given + Pageable pageable = PageRequest.of(0,10); + + //when + Slice resultSlice = userRepository.findAllByCardinalOrderByNameAsc(Status.ACTIVE, cardinal7,pageable); + List result = resultSlice.getContent(); + + //then + assertThat(result) + .hasSize(1) + .extracting(User::getName) + .containsExactly("์ ์ˆœ"); + } + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/service/CardinalGetServiceTest.java b/src/test/java/leets/weeth/domain/user/domain/service/CardinalGetServiceTest.java new file mode 100644 index 000000000..b564019da --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/service/CardinalGetServiceTest.java @@ -0,0 +1,70 @@ +package leets.weeth.domain.user.domain.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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; + +import leets.weeth.domain.user.application.exception.DuplicateCardinalException; +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.repository.CardinalRepository; + +@ExtendWith(MockitoExtension.class) +public class CardinalGetServiceTest { + + // validateCardinal() ๊ฒ€์ฆ๋กœ์ง ํ™•์ธ + @Mock + private CardinalRepository cardinalRepository; + + @InjectMocks + private CardinalGetService cardinalGetService; + + @Test + @DisplayName("findByAdminSide() : ์กด์žฌํ•˜์ง€ ์•Š๋Š” ๊ธฐ์ˆ˜๋ฅผ ๋„ฃ์—ˆ์„ ๋•Œ ์ƒˆ๋กœ ์ €์žฅ๋˜๋Š”์ง€ ํ™•์ธ") + void findByAdminSide() { + //given + given(cardinalRepository.findByCardinalNumber(7)) + .willReturn(Optional.empty()); + + given(cardinalRepository.save(any(Cardinal.class))). + willReturn(Cardinal.builder().cardinalNumber(7).build()); + + //when + Cardinal result = cardinalGetService.findByAdminSide(7); + + //then + assertThat(result.getCardinalNumber()).isEqualTo(7); + } + + @Test + @DisplayName("validateCardinal() : ์ค‘๋ณต๋œ ๊ธฐ์ˆ˜ ์ €์žฅ์„ ๋ฐฉ์ง€ํ•˜๊ณ  ์˜ˆ์™ธ๋ฅผ ๋˜์ง€๋Š”์ง€ ํ™•์ธ") + void validateCardinal() { + //given + given(cardinalRepository.findByCardinalNumber(7)) + .willReturn(Optional.of(Cardinal.builder().cardinalNumber(7).build())); + + //when&then + assertThatThrownBy(() -> cardinalGetService.validateCardinal(7)) + .isInstanceOf(DuplicateCardinalException.class); + } + + @Test + @DisplayName("validateCardinal() : ์ค‘๋ณต๋˜์ง€ ์•Š๋Š” ๊ธฐ์ˆ˜๋ผ๋ฉด ์˜ˆ์™ธ๋ฅผ ๋˜์ง€์ง€์•Š๊ณ  ์ž˜ ์ €์žฅํ•˜๋Š”์ง€ ํ™•์ธ") + void validateCardinal_noException() { + //given + given(cardinalRepository.findByCardinalNumber(7)) + .willReturn(Optional.empty()); + + //when&then + assertThatCode(() -> cardinalGetService.validateCardinal(7)) + .doesNotThrowAnyException(); + } + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/service/UserCardinalGetServiceTest.java b/src/test/java/leets/weeth/domain/user/domain/service/UserCardinalGetServiceTest.java new file mode 100644 index 000000000..c6e303817 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/service/UserCardinalGetServiceTest.java @@ -0,0 +1,103 @@ +package leets.weeth.domain.user.domain.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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; + +import leets.weeth.domain.user.application.exception.CardinalNotFoundException; +import leets.weeth.domain.user.domain.entity.UserCardinal; +import leets.weeth.domain.user.domain.repository.UserCardinalRepository; +import leets.weeth.domain.user.test.fixture.CardinalTestFixture; +import leets.weeth.domain.user.test.fixture.UserTestFixture; + +@ExtendWith(MockitoExtension.class) +public class UserCardinalGetServiceTest { + + @Mock + private UserCardinalRepository userCardinalRepository; + + @InjectMocks + private UserCardinalGetService userCardinalGetService; + + + @Test + @DisplayName("notContains() : ์œ ์ €์˜ ๊ธฐ์ˆ˜ ๋ชฉ๋ก ์ค‘, ํŠน์ • ๊ธฐ์ˆ˜๊ฐ€ ์—†๋Š”์ง€ ํ™•์ธ ") + void notContains() { + //given + var user = UserTestFixture.createActiveUser1(); + var existingCardinal = CardinalTestFixture.createCardinal(7,2025,2); + var targetCardinal = CardinalTestFixture.createCardinal(8,2026,1); + var userCardinal = new UserCardinal(user, existingCardinal); + + given(userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user)) + .willReturn(List.of(userCardinal)); + + //when + boolean result = userCardinalGetService.notContains(user, targetCardinal); + + //then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isCurrent() : ํ˜„์žฌ ์œ ์ €์˜ ์ตœ์‹  ๊ธฐ์ˆ˜๋ณด๋‹ค ์ตœ์‹  ๊ธฐ์ˆ˜์ธ์ง€ ํ™•์ธ") + void isCurrent() { + //given + var user = UserTestFixture.createActiveUser1(); + var oldCardinal = CardinalTestFixture.createCardinal(7,2025,2); + var newCardinal = CardinalTestFixture.createCardinal(8,2026,1); + var userCardinal = new UserCardinal(user, oldCardinal); + + given(userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user)) + .willReturn(List.of(userCardinal)); + + //when + boolean result = userCardinalGetService.isCurrent(user, newCardinal); + + //then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isCurrent(): ์ƒˆ ๊ธฐ์ˆ˜๊ฐ€ ๊ธฐ์กด ์ตœ๋Œ€๋ณด๋‹ค ์ž‘์œผ๋ฉด false ๋ฐ˜ํ™˜") + void isCurrent_returnsFalse_whenOlderCardinal() { + // given + var user = UserTestFixture.createActiveUser1(); + var oldCardinal = CardinalTestFixture.createCardinal(7, 2025, 1); + var newCardinal = CardinalTestFixture.createCardinal(6, 2024, 2); + var userCardinal = new UserCardinal(user, oldCardinal); + + given(userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user)) + .willReturn(List.of(userCardinal)); + + // when + boolean result = userCardinalGetService.isCurrent(user, newCardinal); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("isCurrent(): ์œ ์ €๊ฐ€ ์–ด๋–ค ๊ธฐ์ˆ˜๋„ ๊ฐ€์ง€๊ณ  ์žˆ์ง€ ์•Š์œผ๋ฉด CardinalNotFoundException ๋ฐœ์ƒ") + void isCurrent_throwsException_whenUserHasNoCardinal() { + // given + var user = UserTestFixture.createActiveUser1(); + var newCardinal = CardinalTestFixture.createCardinal(8, 2026, 1); + + given(userCardinalRepository.findAllByUserOrderByCardinalCardinalNumberDesc(user)) + .willReturn(List.of()); + + // when & then + assertThatThrownBy(() -> userCardinalGetService.isCurrent(user, newCardinal)) + .isInstanceOf(CardinalNotFoundException.class); + } + +} diff --git a/src/test/java/leets/weeth/domain/user/domain/service/UserGetServiceTest.java b/src/test/java/leets/weeth/domain/user/domain/service/UserGetServiceTest.java new file mode 100644 index 000000000..36877c2b8 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/domain/service/UserGetServiceTest.java @@ -0,0 +1,72 @@ +package leets.weeth.domain.user.domain.service; + +import leets.weeth.domain.user.application.exception.UserNotFoundException; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.repository.UserRepository; +import org.junit.jupiter.api.DisplayName; +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; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockitoExtension.class) +class UserGetServiceTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserGetService userGetService; + + @Test + @DisplayName("find(Long Id) : ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €์ผ ๋•Œ ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void find_id_userNotFound_throwsException() { + //given + Long userId = 1L; + given(userRepository.findById(userId)).willReturn(Optional.empty()); + + // when & then + assertThrows(UserNotFoundException.class, () -> userGetService.find(userId)); + } + + @Test + @DisplayName("find(String email) : ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์œ ์ €์ผ ๋•Œ ์˜ˆ์™ธ๋ฅผ ๋˜์ง„๋‹ค") + void find_email_userNotFound_throwsException() { + //given + String email = "test@test.com"; + given(userRepository.findByEmail(email)).willReturn(Optional.empty()); + + //when & then + assertThrows(UserNotFoundException.class, () -> userGetService.find(email)); + } + + @Test + @DisplayName("findAll(Pageable pageable) : ๋นˆ ์Šฌ๋ผ์ด์Šค ๋ฐ˜ํ™˜ ์‹œ , ์œ ์ € ์˜ˆ์™ธ ๋˜์ง„๋‹ค") + void findAll_userNotFound_throwsException() { + //given + Pageable pageable = PageRequest.of(0, 10); + Slice emptySlice = new SliceImpl<>(List.of(), pageable, false); + + given(userRepository.findAllByStatusOrderedByCardinalAndName(any(), eq(pageable))) + .willReturn(emptySlice); + + //when & then + assertThrows(UserNotFoundException.class, + () -> userGetService.findAll(pageable)); + + } + +} diff --git a/src/test/java/leets/weeth/domain/user/test/fixture/CardinalTestFixture.java b/src/test/java/leets/weeth/domain/user/test/fixture/CardinalTestFixture.java new file mode 100644 index 000000000..ecb713f41 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/test/fixture/CardinalTestFixture.java @@ -0,0 +1,45 @@ +package leets.weeth.domain.user.test.fixture; + +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.enums.CardinalStatus; + +public class CardinalTestFixture { + + public static Cardinal createCardinal(int cardinalNumber, int year, int semester) { + return Cardinal.builder() + .cardinalNumber(cardinalNumber) + .year(year) + .semester(semester) + .status(CardinalStatus.DONE) + .build(); + } + + public static Cardinal createCardinal(Long id, int cardinalNumber, int year, int semester) { + return Cardinal.builder() + .id(id) + .cardinalNumber(cardinalNumber) + .year(year) + .semester(semester) + .status(CardinalStatus.DONE) + .build(); + } + + public static Cardinal createCardinalInProgress ( int cardinalNumber, int year, int semester) { + return Cardinal.builder() + .cardinalNumber(cardinalNumber) + .year(year) + .semester(semester) + .status(CardinalStatus.IN_PROGRESS) + .build(); + } + + public static Cardinal createCardinalInProgress(Long id, int cardinalNumber, int year, int semester) { + return Cardinal.builder() + .id(id) + .cardinalNumber(cardinalNumber) + .year(year) + .semester(semester) + .status(CardinalStatus.IN_PROGRESS) + .build(); + } +} diff --git a/src/test/java/leets/weeth/domain/user/test/fixture/UserCardinalTestFixture.java b/src/test/java/leets/weeth/domain/user/test/fixture/UserCardinalTestFixture.java new file mode 100644 index 000000000..686031daf --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/test/fixture/UserCardinalTestFixture.java @@ -0,0 +1,12 @@ +package leets.weeth.domain.user.test.fixture; + +import leets.weeth.domain.user.domain.entity.Cardinal; +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.UserCardinal; + +public class UserCardinalTestFixture { + + public static UserCardinal linkUserCardinal(User user, Cardinal cardinal) { + return new UserCardinal(user, cardinal); + } +} diff --git a/src/test/java/leets/weeth/domain/user/test/fixture/UserTestFixture.java b/src/test/java/leets/weeth/domain/user/test/fixture/UserTestFixture.java new file mode 100644 index 000000000..200092104 --- /dev/null +++ b/src/test/java/leets/weeth/domain/user/test/fixture/UserTestFixture.java @@ -0,0 +1,76 @@ +package leets.weeth.domain.user.test.fixture; + +import leets.weeth.domain.user.domain.entity.User; +import leets.weeth.domain.user.domain.entity.enums.Status; + +public class UserTestFixture { + + public static User createActiveUser1() { + return User.builder() + .name("์ ์ˆœ") + .email("test1@test.com") + .status(Status.ACTIVE) + .build(); + } + + public static User createActiveUser1(Long id) { + return User.builder() + .id(id) + .name("์ ์ˆœ") + .email("test1@test.com") + .status(Status.ACTIVE) + .build(); + } + + public static User createActiveUser2() { + return User.builder() + .name("์ ์ˆœ2") + .email("test2@test.com") + .status(Status.ACTIVE) + .build(); + } + + public static User createActiveUser2(Long id) { + return User.builder() + .id(id) + .name("์ ์ˆœ2") + .email("test2@test.com") + .status(Status.ACTIVE) + .build(); + } + + public static User createWaitingUser1() { + return User.builder() + .name("์ˆœ์ ") + .email("test2@test.com") + .status(Status.WAITING) + .build(); + } + + public static User createWaitingUser1(Long id) { + return User.builder() + .id(id) + .name("์ˆœ์ ") + .email("test2@test.com") + .status(Status.WAITING) + .build(); + } + + public static User createWaitingUser2() { + return User.builder() + .name("์ˆœ์ 2") + .email("test3@test.com") + .status(Status.WAITING) + .build(); + } + + public static User createWaitingUser2(Long id) { + return User.builder() + .id(id) + .name("์ˆœ์ 2") + .email("test3@test.com") + .status(Status.WAITING) + .build(); + } + +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 000000000..4747e28c4 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +spring: + profiles: + active: test + jpa: + hibernate: + ddl-auto: create-drop + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect