diff --git a/.github/workflows/assign-reviewer.yml b/.github/workflows/assign-reviewer.yml new file mode 100644 index 00000000..424fd7fa --- /dev/null +++ b/.github/workflows/assign-reviewer.yml @@ -0,0 +1,14 @@ +name: Review Assign + +on: + pull_request: + types: [ opened, ready_for_review ] + +jobs: + assign: + runs-on: ubuntu-latest + steps: + - uses: hkusu/review-assign-action@v1 + with: + assignees: ${{ github.actor }} # assign pull request author + reviewers: s-hwan , rladmstn, sh0723, hwangjokim, junggyo1020 # if draft, assigned when draft is released \ No newline at end of file diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 164ba2cd..9d221035 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -4,13 +4,12 @@ on: push: branches: - deploy + workflow_dispatch: permissions: contents: read - jobs: - build: runs-on: ubuntu-latest @@ -19,13 +18,13 @@ jobs: - name: JDK 21 Setup uses: actions/setup-java@v3 with: - java-version: '21' - distribution: 'temurin' + java-version: "21" + distribution: "temurin" - name: CREDENTIAL_NAME, CREDENTIAL_PW run: | echo "CREDENTIAL_NAME=${{ secrets.CREDENTIAL_NAME }}" >> $GITHUB_ENV - echo "CREDENTIAL_PW=${{ secrets.CREDENTIAL_PW }}" >> $GITHUB_ENV + echo "CREDENTIAL_PW=${{ secrets.CREDENTIAL_PW }}" >> $GITHUB_ENV - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -58,4 +57,4 @@ jobs: chmod 400 private_key.pem sudo scp -i private_key.pem -o StrictHostKeyChecking=no build/libs/algohub-0.0.1-SNAPSHOT.jar ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }}:/home/${{ secrets.EC2_USERNAME }}/algohub.jar sudo ssh -i private_key.pem -o StrictHostKeyChecking=no ${{ secrets.EC2_USERNAME }}@${{ secrets.EC2_HOST }} "pgrep java | xargs kill -9; nohup java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod /home/${{ secrets.EC2_USERNAME }}/algohub.jar > app.log 2>&1 &" - rm -f private_key.pem \ No newline at end of file + rm -f private_key.pem diff --git a/.github/workflows/rc-deploy.yml b/.github/workflows/rc-deploy.yml index f1bbfcfc..ad0cbb89 100644 --- a/.github/workflows/rc-deploy.yml +++ b/.github/workflows/rc-deploy.yml @@ -4,6 +4,7 @@ on: push: branches: - develop + workflow_dispatch: jobs: rc-deploy: @@ -16,8 +17,8 @@ jobs: - name: JDK 21 Setup uses: actions/setup-java@v3 with: - java-version: '21' - distribution: 'temurin' + java-version: "21" + distribution: "temurin" - name: Setup environment variable from WebDAV run: | @@ -28,7 +29,7 @@ jobs: - name: CREDENTIAL_NAME, CREDENTIAL_PW run: | echo "CREDENTIAL_NAME=${{ secrets.CREDENTIAL_NAME }}" >> $GITHUB_ENV - echo "CREDENTIAL_PW=${{ secrets.CREDENTIAL_PW }}" >> $GITHUB_ENV + echo "CREDENTIAL_PW=${{ secrets.CREDENTIAL_PW }}" >> $GITHUB_ENV - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -51,4 +52,4 @@ jobs: - name: Push backend docker image run: | - docker push docker.hwangdo.kr/algohub-server-rc:latest \ No newline at end of file + docker push docker.hwangdo.kr/algohub-server-rc:latest diff --git a/prepare-build.sh b/prepare-build.sh index b01df22a..c7913e2e 100755 --- a/prepare-build.sh +++ b/prepare-build.sh @@ -9,7 +9,6 @@ resources_path="./src/main/resources" is_empty_or_null() { [ -z "$1" ] || [ "$1" = "null" ] } - if is_empty_or_null "$CREDENTIAL_NAME" || is_empty_or_null "$CREDENTIAL_PW"; then echo "필요한 환경변수(CREDENTIAL_NAME, CREDENTIAL_PW) 중 하나 이상이 설정되어 있지 않거나 null입니다." exit 1 diff --git a/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUser.java b/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUser.java index 7fbca173..8cf035f1 100644 --- a/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUser.java +++ b/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUser.java @@ -11,4 +11,5 @@ @Retention(RetentionPolicy.RUNTIME) @Hidden public @interface AuthedUser { + boolean required() default true; } diff --git a/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUserResolver.java b/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUserResolver.java index 10b85dc1..579a9d3e 100644 --- a/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUserResolver.java +++ b/src/main/java/com/gamzabat/algohub/common/annotation/AuthedUserResolver.java @@ -29,11 +29,19 @@ public boolean supportsParameter(MethodParameter parameter) { @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + AuthedUser authedUser = parameter.getParameterAnnotation(AuthedUser.class); + boolean required = authedUser.required(); + String jwt = webRequest.getHeader("Authorization"); - if (jwt != null) - return userRepository.findByEmail(tokenProvider.getUserEmail(jwt)) - .orElseThrow(() -> new UserValidationException("없는 사용자 입니다.")); - else - throw new UserValidationException("로그인 되지 않았습니다."); + + if (jwt == null || jwt.isBlank()) { + if (required) { + throw new UserValidationException("로그인되지 않았습니다."); + } else { + return null; + } + } + return userRepository.findByEmail(tokenProvider.getUserEmail(jwt)) + .orElseThrow(() -> new UserValidationException("없는 사용자입니다.")); } } diff --git a/src/main/java/com/gamzabat/algohub/constants/ApiConstants.java b/src/main/java/com/gamzabat/algohub/constants/ApiConstants.java index 04cd431d..db31ec5a 100644 --- a/src/main/java/com/gamzabat/algohub/constants/ApiConstants.java +++ b/src/main/java/com/gamzabat/algohub/constants/ApiConstants.java @@ -3,7 +3,7 @@ public final class ApiConstants { public static final String SOLVED_AC_PROBLEM_API_URL = "https://solved.ac/api/v3/problem/lookup?problemIds="; public static final String BOJ_USER_PROFILE_URL = "https://www.acmicpc.net/user/"; - public static final String BOJ_PROBLEM_URL = "www.acmicpc.net"; + public static final String BOJ_URL = "www.acmicpc.net"; public static final String SERVER_HTTPS_ENDPOINT = "https://api.algohub.kr"; public static final String RC_SERVER_HTTPS_ENDPOINT = "https://api.rc.algohub.kr"; diff --git a/src/main/java/com/gamzabat/algohub/constants/BOJResultConstants.java b/src/main/java/com/gamzabat/algohub/constants/BOJResultConstants.java index 8319b9fb..cfcb81c0 100644 --- a/src/main/java/com/gamzabat/algohub/constants/BOJResultConstants.java +++ b/src/main/java/com/gamzabat/algohub/constants/BOJResultConstants.java @@ -10,6 +10,7 @@ public class BOJResultConstants { public static final String OVER_OUTPUT_LIMIT = "출력 초과"; public static final String WRONG_OUTPUT_FORMAT = "출력 형식이 잘못되었습니다"; public static final String WRONG_OUTPUT_FORMAT_CUSTOM = "출력 에러"; + public static final String SCORE_UNIT = "점"; private BOJResultConstants() { throw new RuntimeException("Can not instantiate : BOJResultConstants"); diff --git a/src/main/java/com/gamzabat/algohub/enums/JoinRequestStatus.java b/src/main/java/com/gamzabat/algohub/enums/JoinRequestStatus.java new file mode 100644 index 00000000..3c34eba1 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/enums/JoinRequestStatus.java @@ -0,0 +1,17 @@ +package com.gamzabat.algohub.enums; + +public enum JoinRequestStatus { + PENDING("pending"), + APPROVE("approve"), + CANCEL("cancel"), + REJECT("reject"); + private String value; + + private JoinRequestStatus(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/com/gamzabat/algohub/exception/CustomExceptionHandler.java b/src/main/java/com/gamzabat/algohub/exception/CustomExceptionHandler.java index 81a842b9..8b028fcd 100644 --- a/src/main/java/com/gamzabat/algohub/exception/CustomExceptionHandler.java +++ b/src/main/java/com/gamzabat/algohub/exception/CustomExceptionHandler.java @@ -9,12 +9,15 @@ import com.gamzabat.algohub.common.jwt.exception.ExpiredTokenException; import com.gamzabat.algohub.common.jwt.exception.TokenException; import com.gamzabat.algohub.feature.comment.exception.CommentValidationException; +import com.gamzabat.algohub.feature.edgecase.exception.CannotFoundEdgeCaseException; +import com.gamzabat.algohub.feature.edgecase.exception.NotAuthorizedUserException; import com.gamzabat.algohub.feature.group.ranking.exception.CannotFoundRankingException; import com.gamzabat.algohub.feature.group.studygroup.exception.CannotFoundGroupException; import com.gamzabat.algohub.feature.group.studygroup.exception.CannotFoundProblemException; import com.gamzabat.algohub.feature.group.studygroup.exception.CannotFoundUserException; import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; import com.gamzabat.algohub.feature.group.studygroup.exception.InvalidRoleException; +import com.gamzabat.algohub.feature.group.studygroup.exception.JoinRequestException; import com.gamzabat.algohub.feature.image.exception.AwsS3Exception; import com.gamzabat.algohub.feature.notice.exception.NoticeValidationException; import com.gamzabat.algohub.feature.notification.exception.CannotFoundNotificationException; @@ -231,4 +234,28 @@ protected ResponseEntity handleCannotFoundVerificationCodeExcepti .status(HttpStatus.BAD_REQUEST) .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); } + + @ExceptionHandler(CannotFoundEdgeCaseException.class) + protected ResponseEntity handleCannotFoundEdgeCaseException( + CannotFoundEdgeCaseException e) { + return ResponseEntity + .status(e.getHttpStatus()) + .body(new ErrorResponse(e.getHttpStatus().value(), e.getErrors(), null)); + } + + @ExceptionHandler(NotAuthorizedUserException.class) + protected ResponseEntity handleNotAuthorizedUserException( + NotAuthorizedUserException e) { + return ResponseEntity + .status(e.getHttpStatus()) + .body(new ErrorResponse(e.getHttpStatus().value(), e.getError(), null)); + } + + @ExceptionHandler(JoinRequestException.class) + protected ResponseEntity handleCannotFoundVerificationCodeException( + JoinRequestException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null)); + } } diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/controller/EdgeCaseController.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/controller/EdgeCaseController.java new file mode 100644 index 00000000..cb158460 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/controller/EdgeCaseController.java @@ -0,0 +1,76 @@ +package com.gamzabat.algohub.feature.edgecase.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.gamzabat.algohub.common.annotation.AuthedUser; +import com.gamzabat.algohub.exception.RequestException; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseSortType; +import com.gamzabat.algohub.feature.edgecase.dto.CreateEdgeCaseRequest; +import com.gamzabat.algohub.feature.edgecase.dto.GetEdgeCaseListResponse; +import com.gamzabat.algohub.feature.edgecase.dto.TogleEdgeCaseResponse; +import com.gamzabat.algohub.feature.edgecase.service.EdgeCaseService; +import com.gamzabat.algohub.feature.user.domain.User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/edge-case") +@Tag(name = "반례 게시판 API", description = "반례 게시판 관련 API") +public class EdgeCaseController { + private final EdgeCaseService edgeCaseService; + + @PostMapping + @Operation(summary = "반례 등록") + public ResponseEntity createEdgeCase(@AuthedUser User user, + @RequestBody @Valid CreateEdgeCaseRequest createEdgeCaseRequest, + Errors errors) { + if (errors.hasErrors()) + throw new RequestException("올바르지 않은 요청입니다.", errors); + + edgeCaseService.createEdgeCase(user, createEdgeCaseRequest); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/list") + @Operation(summary = "반례리스트 조회") + public ResponseEntity getEdgeCaseList( + @AuthedUser(required = false) User user, + @RequestParam(required = false) Integer problemNumber, + @RequestParam(required = false, defaultValue = "RECENT") EdgeCaseSortType sort + ) { + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(user, problemNumber, sort); + + return ResponseEntity.ok().body(response); + } + + @DeleteMapping("/{edgeCaseId}") + @Operation(summary = "반례 삭제") + public ResponseEntity deleteEdgeCase(@AuthedUser User user, @PathVariable Long edgeCaseId) { + edgeCaseService.deleteEdgeCase(user, edgeCaseId); + + return ResponseEntity.ok().build(); + } + + @PatchMapping(value = "/{edgeCaseId}/like") + @Operation(summary = "반례 좋아요 토글") + public ResponseEntity addEdgeCaseLike(@AuthedUser User user, @PathVariable Long edgeCaseId) { + TogleEdgeCaseResponse result = edgeCaseService.togleEdgeCaseLike(user, edgeCaseId); + + return ResponseEntity.ok().body(result); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCase.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCase.java new file mode 100644 index 00000000..1338a469 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCase.java @@ -0,0 +1,71 @@ +package com.gamzabat.algohub.feature.edgecase.domain; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.SQLDelete; + +import com.gamzabat.algohub.feature.user.domain.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@SQLDelete(sql = "UPDATE edge_case SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +public class EdgeCase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private Integer level; + private String link; + private Integer problemNumber; + private String title; + @Column(columnDefinition = "TEXT") + private String input; + @Column(columnDefinition = "TEXT") + private String output; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User author; + + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + + @Column(nullable = false) + private int likeCount = 0; + + @Builder + public EdgeCase(Integer level, String link, Integer problemNumber, String title, String input, String output, + User author) { + this.level = level; + this.link = link; + this.problemNumber = problemNumber; + this.title = title; + this.input = input; + this.output = output; + this.author = author; + this.createdAt = LocalDateTime.now(); + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseLike.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseLike.java new file mode 100644 index 00000000..ff439c3c --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseLike.java @@ -0,0 +1,42 @@ +package com.gamzabat.algohub.feature.edgecase.domain; + +import org.hibernate.annotations.SQLDelete; + +import com.gamzabat.algohub.feature.user.domain.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class EdgeCaseLike { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "edge_case_id", nullable = false) + private EdgeCase edgeCase; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Builder + public EdgeCaseLike(EdgeCase edgeCase, User user) { + this.edgeCase = edgeCase; + this.user = user; + } + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseSortType.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseSortType.java new file mode 100644 index 00000000..1e67374e --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/domain/EdgeCaseSortType.java @@ -0,0 +1,7 @@ +package com.gamzabat.algohub.feature.edgecase.domain; + +public enum EdgeCaseSortType { + RECENT, // 최신순 (기본값) + LIKE, // 좋아요순 + OLD // 오래된순 +} \ No newline at end of file diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/CreateEdgeCaseRequest.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/CreateEdgeCaseRequest.java new file mode 100644 index 00000000..ba6de67f --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/CreateEdgeCaseRequest.java @@ -0,0 +1,10 @@ +package com.gamzabat.algohub.feature.edgecase.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Builder; + +@Builder + public record CreateEdgeCaseRequest(@NotBlank(message = "문제 링크 입력은 필수입니다.") String link, + @NotBlank(message = "반례 input 입력은 필수입니다.") String input, + @NotBlank(message = "반례 output 입력은 필수입니다.") String output){ +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseListResponse.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseListResponse.java new file mode 100644 index 00000000..fb94fb94 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseListResponse.java @@ -0,0 +1,10 @@ +package com.gamzabat.algohub.feature.edgecase.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record GetEdgeCaseListResponse( + List edgeCaseList) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseResponse.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseResponse.java new file mode 100644 index 00000000..d9d66c4c --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/GetEdgeCaseResponse.java @@ -0,0 +1,15 @@ +package com.gamzabat.algohub.feature.edgecase.dto; + +import lombok.Builder; + +@Builder +public record GetEdgeCaseResponse( + Integer edgeCaseId, + Integer level, + Integer problemNumber, + String title, + String input, + String output, + Integer like, + Boolean isLiked) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/TogleEdgeCaseResponse.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/TogleEdgeCaseResponse.java new file mode 100644 index 00000000..71519945 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/dto/TogleEdgeCaseResponse.java @@ -0,0 +1,13 @@ +package com.gamzabat.algohub.feature.edgecase.dto; + +import lombok.Getter; + +@Getter +public class TogleEdgeCaseResponse { + private final Boolean islike; + + public TogleEdgeCaseResponse(Boolean islike) { + this.islike = islike; + } +} + diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/CannotFoundEdgeCaseException.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/CannotFoundEdgeCaseException.java new file mode 100644 index 00000000..696d50ed --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/CannotFoundEdgeCaseException.java @@ -0,0 +1,16 @@ +package com.gamzabat.algohub.feature.edgecase.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class CannotFoundEdgeCaseException extends RuntimeException { + private final String errors; + private final HttpStatus httpStatus; + + public CannotFoundEdgeCaseException(String errors, HttpStatus httpStatus) { + this.errors = errors; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/NotAuthorizedUserException.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/NotAuthorizedUserException.java new file mode 100644 index 00000000..1906ac33 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/exception/NotAuthorizedUserException.java @@ -0,0 +1,16 @@ +package com.gamzabat.algohub.feature.edgecase.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class NotAuthorizedUserException extends RuntimeException { + private final String error; + private final HttpStatus httpStatus; + + public NotAuthorizedUserException(String error, HttpStatus httpStatus) { + this.error = error; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseLikeRepository.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseLikeRepository.java new file mode 100644 index 00000000..ae3afe08 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseLikeRepository.java @@ -0,0 +1,16 @@ +package com.gamzabat.algohub.feature.edgecase.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCase; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseLike; +import com.gamzabat.algohub.feature.user.domain.User; + +public interface EdgeCaseLikeRepository extends JpaRepository { + Optional findByEdgeCaseAndUser(EdgeCase edgeCase, User user); + List findAllByEdgeCase(EdgeCase edgeCase); + List findByUserAndEdgeCaseIn(User user, List edgeCases); +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseRepository.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseRepository.java new file mode 100644 index 00000000..1a0edf38 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/repository/EdgeCaseRepository.java @@ -0,0 +1,24 @@ +package com.gamzabat.algohub.feature.edgecase.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCase; + +public interface EdgeCaseRepository extends JpaRepository { + // RECENT + List findAllByProblemNumberOrderByCreatedAtDesc(Integer problemNumber); + + List findAllByOrderByCreatedAtDesc(); + + // LIKE + List findAllByProblemNumberOrderByLikeCountDesc(Integer problemNumber); + + List findAllByOrderByLikeCountDesc(); + + // OLD + List findAllByProblemNumberOrderByCreatedAtAsc(Integer problemNumber); + + List findAllByOrderByCreatedAtAsc(); +} diff --git a/src/main/java/com/gamzabat/algohub/feature/edgecase/service/EdgeCaseService.java b/src/main/java/com/gamzabat/algohub/feature/edgecase/service/EdgeCaseService.java new file mode 100644 index 00000000..a7044978 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/edgecase/service/EdgeCaseService.java @@ -0,0 +1,160 @@ +package com.gamzabat.algohub.feature.edgecase.service; + +import static com.gamzabat.algohub.constants.ApiConstants.*; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.JsonNode; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCase; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseLike; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseSortType; +import com.gamzabat.algohub.feature.edgecase.dto.CreateEdgeCaseRequest; +import com.gamzabat.algohub.feature.edgecase.dto.GetEdgeCaseListResponse; +import com.gamzabat.algohub.feature.edgecase.dto.GetEdgeCaseResponse; +import com.gamzabat.algohub.feature.edgecase.dto.TogleEdgeCaseResponse; +import com.gamzabat.algohub.feature.edgecase.exception.CannotFoundEdgeCaseException; +import com.gamzabat.algohub.feature.edgecase.exception.NotAuthorizedUserException; +import com.gamzabat.algohub.feature.edgecase.repository.EdgeCaseLikeRepository; +import com.gamzabat.algohub.feature.edgecase.repository.EdgeCaseRepository; +import com.gamzabat.algohub.feature.problem.exception.NotBojLinkException; +import com.gamzabat.algohub.feature.problem.service.ProblemService; +import com.gamzabat.algohub.feature.user.domain.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EdgeCaseService { + private final EdgeCaseRepository edgeCaseRepository; + private final ProblemService problemService; + private final EdgeCaseLikeRepository edgeCaseLikeRepository; + + public void createEdgeCase(User user, CreateEdgeCaseRequest request) { + User author = user; + String link = request.link(); + + String number = getProblemId(link); + JsonNode apiResult = problemService.fetchProblemDetails(number); + int level = problemService.getProblemLevel(apiResult); + String title = problemService.getProblemTitle(apiResult); + + saveEdgeCase(author, request, level, title, Integer.parseInt(number)); + } + + private void saveEdgeCase(User author, CreateEdgeCaseRequest request, int level, String title, int number) { + EdgeCase edgeCase = EdgeCase.builder().input(request.input()).level(level).title(title).link( + request.link()).output(request.output()).problemNumber(number).author(author).build(); + + edgeCaseRepository.save(edgeCase); + } + + public GetEdgeCaseListResponse getEdgeCaseList(User user, Integer problemNumber, EdgeCaseSortType sort) { + List edgeCaseList; + Set likedEdgeCaseIds = Collections.emptySet(); + + if (problemNumber == null) { + switch (sort) { + case LIKE: + edgeCaseList = edgeCaseRepository.findAllByOrderByLikeCountDesc(); + break; + case OLD: + edgeCaseList = edgeCaseRepository.findAllByOrderByCreatedAtAsc(); + break; + case RECENT: + default: + edgeCaseList = edgeCaseRepository.findAllByOrderByCreatedAtDesc(); + break; + } + } else { + switch (sort) { + case LIKE: + edgeCaseList = edgeCaseRepository.findAllByProblemNumberOrderByLikeCountDesc(problemNumber); + break; + case OLD: + edgeCaseList = edgeCaseRepository.findAllByProblemNumberOrderByCreatedAtAsc(problemNumber); + break; + case RECENT: + default: + edgeCaseList = edgeCaseRepository.findAllByProblemNumberOrderByCreatedAtDesc(problemNumber); + break; + } + } + + if (user != null && !edgeCaseList.isEmpty()) { + List myLikes = + edgeCaseLikeRepository.findByUserAndEdgeCaseIn(user, edgeCaseList); + + likedEdgeCaseIds = myLikes.stream() + .map(like -> like.getEdgeCase().getId()) + .collect(Collectors.toSet()); + } + + final Set likedIds = likedEdgeCaseIds; + + List responseList = edgeCaseList.stream() + .map(edgeCase -> new GetEdgeCaseResponse( + edgeCase.getId().intValue(), + edgeCase.getLevel(), + edgeCase.getProblemNumber(), + edgeCase.getTitle(), + edgeCase.getInput(), + edgeCase.getOutput(), + edgeCase.getLikeCount(), + likedIds.contains(edgeCase.getId()) + )) + .collect(Collectors.toList()); + + return new GetEdgeCaseListResponse(responseList); + } + + @Transactional + public void deleteEdgeCase(User user, Long edgeCaseId) { + EdgeCase edgeCase = edgeCaseRepository.findById(edgeCaseId) + .orElseThrow(() -> new CannotFoundEdgeCaseException("존재하지 않는 반례입니다.", HttpStatus.NOT_FOUND)); + + User author = edgeCase.getAuthor(); + + if (!user.getId().equals(author.getId())) + throw new NotAuthorizedUserException("반례를 삭제할 권한이 없습니다.", HttpStatus.FORBIDDEN); + List likes = edgeCaseLikeRepository.findAllByEdgeCase(edgeCase); + edgeCaseLikeRepository.deleteAll(likes); + edgeCaseRepository.delete(edgeCase); + } + + @Transactional + public TogleEdgeCaseResponse togleEdgeCaseLike(User user, Long edgeCaseId) { + EdgeCase edgeCase = edgeCaseRepository.findById(edgeCaseId) + .orElseThrow(() -> new CannotFoundEdgeCaseException("존재하지 않는 반례입니다.", HttpStatus.NOT_FOUND)); + + EdgeCaseLike edgeCaseLike = edgeCaseLikeRepository.findByEdgeCaseAndUser(edgeCase, user).orElse(null); + Boolean isLike = false; + if (edgeCaseLike == null) { + edgeCaseLike = EdgeCaseLike.builder().user(user).edgeCase(edgeCase).build(); + edgeCaseLikeRepository.save(edgeCaseLike); + edgeCase.increaseLikeCount(); + isLike = true; + } else { + edgeCaseLikeRepository.delete(edgeCaseLike); + edgeCase.decreaseLikeCount(); + } + + return new TogleEdgeCaseResponse(isLike); + } + + private String getProblemId(String url) { + String[] parts = url.split("/"); + if (parts.length < 3 || !parts[2].equals(BOJ_URL)) + throw new NotBojLinkException(HttpStatus.BAD_REQUEST.value(), "백준 링크가 아닙니다"); + return parts[parts.length - 1]; + } + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/JoinRequestController.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/JoinRequestController.java new file mode 100644 index 00000000..355df0d7 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/JoinRequestController.java @@ -0,0 +1,60 @@ +package com.gamzabat.algohub.feature.group.studygroup.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.GetMapping; +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 com.gamzabat.algohub.common.annotation.AuthedUser; +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; +import com.gamzabat.algohub.feature.group.studygroup.dto.UpdateJoinRequestStatusRequest; +import com.gamzabat.algohub.feature.group.studygroup.exception.JoinRequestException; +import com.gamzabat.algohub.feature.group.studygroup.service.JoinRequestService; +import com.gamzabat.algohub.feature.user.domain.User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +@Tag(name = "그룹 가입 요청API", description = "스터디 그룹 가입 요청 관련 API") +public class JoinRequestController { + private final JoinRequestService joinRequestService; + + @PostMapping(value = "/groups/{groupId}/join-request") + @Operation(summary = "그룹 가입 요청 API", description = "스터디 그룹에 가입 요청을 보내는 API") + public ResponseEntity joinRequest(@AuthedUser User user, @PathVariable Long groupId) { + joinRequestService.joinRequest(user, groupId); + return ResponseEntity.ok().build(); + } + + @GetMapping(value = "/groups/{groupId}/join-request") + @Operation(summary = "그룹 가입 요청 목록 조회 API", description = "스터디 그룹 가입 요청 목록을 조회하는 API") + public ResponseEntity> getAllJoinRequests(@AuthedUser User user, @PathVariable Long groupId) { + List response = joinRequestService.getAllJoinRequests(user, groupId); + + return ResponseEntity.ok().body(response); + } + + @PostMapping(value = "/join-request/{requestId}") + @Operation(summary = "그룹 가입 요청 승인 / 거절", description = "스터디 그룹 가입 요청을 승인 / 거절하는 API") + public ResponseEntity updateRequest( + @AuthedUser User user, + @PathVariable Long requestId, + @RequestBody @Valid UpdateJoinRequestStatusRequest request, Errors errors) { + if (errors.hasErrors()) + throw new JoinRequestException("가입 요청이 올바르지 않습니다."); + joinRequestService.updateJoinRequest(user, requestId, request); + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/StudyGroupController.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/StudyGroupController.java index 53fe19b4..ec97c5da 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/StudyGroupController.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/controller/StudyGroupController.java @@ -2,6 +2,11 @@ import java.util.List; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.Errors; @@ -195,4 +200,13 @@ public ResponseEntity> getStudyGroupSettings(@Auth List responses = studyGroupService.getStudyGroupSettings(user); return ResponseEntity.ok().body(responses); } + + @GetMapping(value = "/groups/search") + @Operation(summary = "그룹 검색 API", description = "홈 화면에서 그룹을 검색하는 API") + public ResponseEntity> getSearchedGroupList(@RequestParam String searchPattern, + @ParameterObject @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC, page = 0) Pageable pageable) { + Page responses = studyGroupService.getSearchedStudyGroupList(searchPattern, pageable); + return ResponseEntity.ok().body(responses); + } + } diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/GroupMember.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/GroupMember.java index 4ec86f55..bc86ae1c 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/GroupMember.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/GroupMember.java @@ -14,8 +14,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import jakarta.validation.constraints.NotNull; import lombok.Builder; import lombok.Getter; @@ -25,6 +27,12 @@ @Getter @NoArgsConstructor @DynamicUpdate +@Table( + name = "group_member", + indexes = { + @Index(name = "idx_gm_user_visible_grp", columnList = "user_id,is_visible,study_group_id") + } +) public class GroupMember { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/JoinRequest.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/JoinRequest.java new file mode 100644 index 00000000..ed5b7c3d --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/domain/JoinRequest.java @@ -0,0 +1,50 @@ +package com.gamzabat.algohub.feature.group.studygroup.domain; + +import com.gamzabat.algohub.enums.JoinRequestStatus; +import com.gamzabat.algohub.feature.user.domain.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +public class JoinRequest { + @Id + @GeneratedValue + Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "group_id") + private StudyGroup group; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User requester; + + @Enumerated(EnumType.STRING) + private JoinRequestStatus status = JoinRequestStatus.PENDING; + + public JoinRequest(StudyGroup group, User requester) { + this.group = group; + this.requester = requester; + } + + public void updateStatus(JoinRequestStatus status) { + if (status != JoinRequestStatus.PENDING) { + return; + } + this.status = status; + } + +} + + diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetGroupResponse.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetGroupResponse.java index d8b063b1..d9da6450 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetGroupResponse.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetGroupResponse.java @@ -28,4 +28,5 @@ public GetGroupResponse(Long id, String name, LocalDate startDate, LocalDate end this.role = role; this.ownerNickname = ownerNickname; } + } diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetStudyGroupResponse.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetStudyGroupResponse.java index dc0c8b85..a3dc5b73 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetStudyGroupResponse.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/GetStudyGroupResponse.java @@ -34,4 +34,5 @@ public static GetStudyGroupResponse toDTO(StudyGroup group, GroupMember member, isVisible ); } + } diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/UpdateJoinRequestStatusRequest.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/UpdateJoinRequestStatusRequest.java new file mode 100644 index 00000000..71920e38 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/dto/UpdateJoinRequestStatusRequest.java @@ -0,0 +1,8 @@ +package com.gamzabat.algohub.feature.group.studygroup.dto; + +import com.gamzabat.algohub.enums.JoinRequestStatus; + +import jakarta.validation.constraints.NotNull; + +public record UpdateJoinRequestStatusRequest(@NotNull(message = "status 는 필수입니다.") JoinRequestStatus status) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/exception/JoinRequestException.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/exception/JoinRequestException.java new file mode 100644 index 00000000..da3a990e --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/exception/JoinRequestException.java @@ -0,0 +1,7 @@ +package com.gamzabat.algohub.feature.group.studygroup.exception; + +public class JoinRequestException extends RuntimeException { + public JoinRequestException(String error) { + super(error); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/GroupMemberRepository.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/GroupMemberRepository.java index ed0f4ff1..34156bdf 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/GroupMemberRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/GroupMemberRepository.java @@ -1,11 +1,13 @@ package com.gamzabat.algohub.feature.group.studygroup.repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.gamzabat.algohub.feature.group.studygroup.domain.GroupMember; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; @@ -31,4 +33,13 @@ public interface GroupMemberRepository extends JpaRepository @Modifying @Query("delete from GroupMember gm where gm.studyGroup = :studyGroup") void deleteAllByStudyGroup(StudyGroup studyGroup); + + @Query("SELECT COUNT(gm) FROM GroupMember gm WHERE gm.studyGroup = :studyGroup AND gm.joinDate BETWEEN :start AND :end") + Integer countByStudyGroupAndJoinDateBetween(@Param("studyGroup") StudyGroup studyGroup, + @Param("start") LocalDate start, @Param("end") LocalDate end); + + @Query("SELECT COUNT(gm) FROM GroupMember gm WHERE gm.studyGroup = :studyGroup AND gm.joinDate < :date") + Integer countByStudyGroupAndJoinDateBefore(@Param("studyGroup") StudyGroup studyGroup, + @Param("date") LocalDate date); + } diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/JoinRequestRepository.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/JoinRequestRepository.java new file mode 100644 index 00000000..092e21ca --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/JoinRequestRepository.java @@ -0,0 +1,24 @@ +package com.gamzabat.algohub.feature.group.studygroup.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; + +public interface JoinRequestRepository extends JpaRepository { + + boolean existsByGroup_IdAndRequester_Id(Long groupId, Long userId); + + @Query(""" + select jr + from JoinRequest jr + join fetch jr.requester u + join fetch jr.group g + where g.id = :groupId + """) + List findAllByGroupIdWithFetch(@Param("groupId") Long groupId); + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/StudyGroupRepository.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/StudyGroupRepository.java index 93606f42..dc71bbe0 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/StudyGroupRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/repository/StudyGroupRepository.java @@ -2,8 +2,11 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; import com.gamzabat.algohub.feature.group.studygroup.repository.querydsl.CustomStudyGroupRepository; @@ -14,4 +17,11 @@ public interface StudyGroupRepository extends JpaRepository, C @Query("select sg from StudyGroup sg where sg.id = :id and sg.deletedAt is null") Optional findById(Long id); + + @Query(value = """ + select * from study_group sg + where match (sg.name,sg.introduction) + against(:searchPattern in boolean mode) + """, nativeQuery = true) + Page findBySearchPattern(@Param("searchPattern") String searchPattern, Pageable pageable); } diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/JoinRequestService.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/JoinRequestService.java new file mode 100644 index 00000000..5fa35895 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/JoinRequestService.java @@ -0,0 +1,113 @@ +package com.gamzabat.algohub.feature.group.studygroup.service; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gamzabat.algohub.enums.JoinRequestStatus; +import com.gamzabat.algohub.exception.StudyGroupValidationException; +import com.gamzabat.algohub.feature.group.ranking.domain.Ranking; +import com.gamzabat.algohub.feature.group.ranking.repository.RankingRepository; +import com.gamzabat.algohub.feature.group.studygroup.domain.GroupMember; +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.dto.UpdateJoinRequestStatusRequest; +import com.gamzabat.algohub.feature.group.studygroup.etc.RoleOfGroupMember; +import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; +import com.gamzabat.algohub.feature.group.studygroup.exception.JoinRequestException; +import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.JoinRequestRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.notification.domain.NotificationSetting; +import com.gamzabat.algohub.feature.notification.repository.NotificationSettingRepository; +import com.gamzabat.algohub.feature.user.domain.User; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JoinRequestService { + private final StudyGroupRepository studyGroupRepository; + private final GroupMemberRepository groupMemberRepository; + private final JoinRequestRepository joinRequestRepository; + private final NotificationSettingRepository notificationSettingRepository; + private final RankingRepository rankingRepository; + private final StudyGroupService studyGroupService; + + @Transactional + public void joinRequest(User user, Long groupId) { + StudyGroup studyGroup = studyGroupRepository.findById(groupId) + .orElseThrow(() -> new StudyGroupValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹 입니다.")); + if (groupMemberRepository.existsByUserAndStudyGroup(user, studyGroup)) { + throw new GroupMemberValidationException(HttpStatus.BAD_REQUEST.value(), "이미 가입한 그룹입니다"); + } + if (joinRequestRepository.existsByGroup_IdAndRequester_Id(groupId, user.getId())) { + throw new JoinRequestException("이미 요청한 그룹입니다."); + } + + JoinRequest request = new JoinRequest(studyGroup, user); + joinRequestRepository.save(request); + log.info("success to join request group = {}", groupId); + } + + @Transactional(readOnly = true) + public List getAllJoinRequests(User user, Long groupId) { + StudyGroup studyGroup = studyGroupRepository.findById(groupId) + .orElseThrow(() -> new StudyGroupValidationException( + HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹 입니다.")); + final boolean noAuthority = groupMemberRepository.findByUserAndStudyGroup(user, studyGroup) + .map(RoleOfGroupMember::isParticipant) + .orElse(true); + if (noAuthority) { + throw new JoinRequestException("요청 목록을 조회할 권한이 없습니다."); + } + return joinRequestRepository.findAllByGroupIdWithFetch(groupId); + } + + @Transactional + public void updateJoinRequest(User user, Long requestId, UpdateJoinRequestStatusRequest request) { + JoinRequest joinRequest = joinRequestRepository.findById(requestId) + .orElseThrow(() -> new JoinRequestException("해당 요청이 존재하지 않습니다")); + StudyGroup studyGroup = studyGroupRepository.findById(joinRequest.getGroup().getId()) + .orElseThrow(() -> new StudyGroupValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹입니다.")); + GroupMember groupMember = groupMemberRepository.findByUserAndStudyGroup(user, studyGroup) + .orElseThrow(() -> new GroupMemberValidationException(HttpStatus.NOT_FOUND.value(), "해당 그룹의 멤버가 아닙니다.")); + if (RoleOfGroupMember.isParticipant(groupMember)) { + throw new JoinRequestException("승인 권한이 없습니다."); + } + if (request.status() == JoinRequestStatus.APPROVE) { + joinRequest.updateStatus(request.status()); + GroupMember newGroupMember = GroupMember.builder() + .user(joinRequest.getRequester()) + .studyGroup(studyGroup) + .joinDate(LocalDate.now()) + .role(RoleOfGroupMember.PARTICIPANT) + .build(); + groupMemberRepository.save(newGroupMember); + + notificationSettingRepository.save( + NotificationSetting.builder().member(newGroupMember).build() + ); + + rankingRepository.save(Ranking.builder() + .member(newGroupMember) + .currentRank(groupMemberRepository.countByStudyGroup(studyGroup)) + .solvedCount(0) + .rankDiff("-") + .build() + ); + studyGroupService.sendNewMemberNotification(studyGroup, newGroupMember); + + joinRequestRepository.delete(joinRequest); + } else if (request.status() == JoinRequestStatus.REJECT) { + joinRequestRepository.delete(joinRequest); + } + log.info("success to approve/reject for join request group = {}", studyGroup.getId()); + } + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/StudyGroupService.java b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/StudyGroupService.java index 20de6fbe..fa352f5d 100644 --- a/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/StudyGroupService.java +++ b/src/main/java/com/gamzabat/algohub/feature/group/studygroup/service/StudyGroupService.java @@ -11,6 +11,8 @@ import java.util.Optional; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,6 +52,7 @@ import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; import com.gamzabat.algohub.feature.group.studygroup.repository.BookmarkedStudyGroupRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.JoinRequestRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.image.service.ImageService; import com.gamzabat.algohub.feature.notice.repository.NoticeCommentRepository; @@ -57,6 +60,7 @@ import com.gamzabat.algohub.feature.notice.repository.NoticeRepository; import com.gamzabat.algohub.feature.notification.domain.NotificationSetting; import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.repository.NotificationSettingRepository; import com.gamzabat.algohub.feature.notification.service.NotificationService; import com.gamzabat.algohub.feature.problem.domain.Problem; @@ -84,6 +88,7 @@ public class StudyGroupService { private final RankingRepository rankingRepository; private final NoticeRepository noticeRepository; private final NoticeCommentRepository noticeCommentRepository; + private final JoinRequestRepository joinRequestRepository; private final SolutionCommentRepository solutionCommentRepository; private final NoticeReadRepository noticeReadRepository; private final ObjectProvider studyGroupServiceProvider; @@ -91,6 +96,19 @@ public class StudyGroupService { private final NotificationService notificationService; private final DiscordWebhookService webhookService; + private static boolean isDone(LocalDate today, StudyGroup group) { + return group.getEndDate() != null && group.getEndDate().isBefore(today); + } + + private static boolean isInProgress(LocalDate today, StudyGroup group) { + return !(group.getStartDate() == null || group.getStartDate().isAfter(today)) + && !(group.getEndDate() == null || group.getEndDate().isBefore(today)); + } + + private static boolean isQueued(LocalDate today, StudyGroup group) { + return group.getStartDate() != null && group.getStartDate().isAfter(today); + } + @Transactional public GroupCodeResponse createGroup(User user, CreateGroupRequest request, MultipartFile profileImage) { String inviteCode = NanoIdUtils.randomNanoId(); @@ -293,19 +311,6 @@ public GetStudyGroupListsResponse getStudyGroupList(User user) { return response; } - private static boolean isDone(LocalDate today, StudyGroup group) { - return group.getEndDate() != null && group.getEndDate().isBefore(today); - } - - private static boolean isInProgress(LocalDate today, StudyGroup group) { - return !(group.getStartDate() == null || group.getStartDate().isAfter(today)) - && !(group.getEndDate() == null || group.getEndDate().isBefore(today)); - } - - private static boolean isQueued(LocalDate today, StudyGroup group) { - return group.getStartDate() != null && group.getStartDate().isAfter(today); - } - private GetStudyGroupResponse getStudyGroupResponseDTO(User user, StudyGroup group) { GroupMember member = groupMemberRepository.findByUserAndStudyGroup(user, group) .orElseThrow(() -> new GroupMemberValidationException(HttpStatus.FORBIDDEN.value(), "참여하지 않은 스터디 그룹입니다.")); @@ -606,7 +611,7 @@ private boolean isVisible(StudyGroup group, User user) { return groupMemberRepository.existsByUserAndStudyGroupAndIsVisible(user, group, true); } - private void sendNewMemberNotification(StudyGroup studyGroup, GroupMember newMember) { + void sendNewMemberNotification(StudyGroup studyGroup, GroupMember newMember) { List members = groupMemberRepository.findAllByStudyGroup(studyGroup) .stream() .filter(member -> !member.getId().equals(newMember.getId())) @@ -617,7 +622,8 @@ private void sendNewMemberNotification(StudyGroup studyGroup, GroupMember newMem members, null, null, NotificationCategory.NEW_MEMBER_JOINED, - NotificationCategory.NEW_MEMBER_JOINED.getMessage(newMember.getUser().getNickname()) + NotificationCategory.NEW_MEMBER_JOINED.getMessage(newMember.getUser().getNickname()), + NotificationType.STUDY_GROUP ); } @@ -634,4 +640,23 @@ public List getStudyGroupSettings(User user) { log.info("success to get my study groups settings"); return response; } -} + + public Page getSearchedStudyGroupList(String searchPattern, Pageable pageable) { + String p = searchPattern == null ? null : searchPattern.trim(); + if (p == null || p.isEmpty()) { + return Page.empty(pageable); + } + Page groups = groupRepository.findBySearchPattern(p, pageable); + return groups.map(group -> new GetGroupResponse( + group.getId(), + group.getName(), + group.getStartDate(), + group.getEndDate(), + group.getIntroduction(), + group.getGroupImage(), + null, + null + )); + } + +} \ No newline at end of file diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/controller/NotificationController.java b/src/main/java/com/gamzabat/algohub/feature/notification/controller/NotificationController.java index 6b8813ac..35ab14ac 100644 --- a/src/main/java/com/gamzabat/algohub/feature/notification/controller/NotificationController.java +++ b/src/main/java/com/gamzabat/algohub/feature/notification/controller/NotificationController.java @@ -13,6 +13,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; 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.servlet.mvc.method.annotation.SseEmitter; @@ -21,6 +22,8 @@ import com.gamzabat.algohub.feature.notification.dto.EditNotificationSettingRequest; import com.gamzabat.algohub.feature.notification.dto.GetNotificationResponse; import com.gamzabat.algohub.feature.notification.dto.GetNotificationSettingResponse; +import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.service.NotificationService; import com.gamzabat.algohub.feature.notification.service.NotificationSettingService; import com.gamzabat.algohub.feature.user.domain.User; @@ -47,8 +50,9 @@ public SseEmitter streamNotifications(@AuthedUser User user, @GetMapping @Operation(summary = "알림 목록 조회 API") - public ResponseEntity> getNotifications(@AuthedUser User user) { - return ResponseEntity.ok().body(notificationService.getNotifications(user)); + public ResponseEntity> getNotifications(@AuthedUser User user, @RequestParam(required = false) + NotificationType type) { + return ResponseEntity.ok().body(notificationService.getNotifications(user, type)); } @PatchMapping diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/domain/Notification.java b/src/main/java/com/gamzabat/algohub/feature/notification/domain/Notification.java index 3dfb9846..e0396512 100644 --- a/src/main/java/com/gamzabat/algohub/feature/notification/domain/Notification.java +++ b/src/main/java/com/gamzabat/algohub/feature/notification/domain/Notification.java @@ -3,11 +3,16 @@ import java.time.LocalDateTime; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.problem.domain.Problem; import com.gamzabat.algohub.feature.solution.domain.Solution; import com.gamzabat.algohub.feature.user.domain.User; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -39,13 +44,16 @@ public class Notification { @JoinColumn(name = "solution_id") private Solution solution; private String message; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationType type; private boolean isRead; private String subContent; private LocalDateTime createdAt; @Builder public Notification(User user, StudyGroup studyGroup, Problem problem, Solution solution, String message, - boolean isRead, String subContent) { + boolean isRead, String subContent, NotificationType type) { this.user = user; this.studyGroup = studyGroup; this.problem = problem; @@ -54,6 +62,7 @@ public Notification(User user, StudyGroup studyGroup, Problem problem, Solution this.isRead = isRead; this.subContent = subContent; this.createdAt = LocalDateTime.now(); + this.type = type; } public void updateIsRead() { diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationCategory.java b/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationCategory.java index fd48e4f5..bdc10a8e 100644 --- a/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationCategory.java +++ b/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationCategory.java @@ -1,19 +1,29 @@ package com.gamzabat.algohub.feature.notification.enums; public enum NotificationCategory { - PROBLEM_STARTED("[%s] 문제가 시작되었습니다! 지금 도전해보세요!"), - NEW_SOLUTION_POSTED("%s님이 새로운 풀이를 등록했습니다! 풀이를 확인하고 의견을 나눠보세요."), - NEW_MEMBER_JOINED("%s님이 스터디에 합류했습니다!"), - NEW_COMMENT_POSTED("%s님이 내 풀이에 코멘트를 남겼습니다! 어떤 리뷰인지 확인해보세요."), - PROBLEM_DEADLINE_REACHED("[%s] 문제의 마감이 오늘입니다! 아직 해결하지 못했다면 지금 도전해보세요!"); + PROBLEM_STARTED(NotificationType.PROBLEM, "[%s] 문제가 시작되었습니다! 지금 도전해보세요!"), + PROBLEM_DEADLINE_REACHED(NotificationType.PROBLEM, "[%s] 문제의 마감이 오늘입니다! 아직 해결하지 못했다면 지금 도전해보세요!"), + + NEW_SOLUTION_POSTED(NotificationType.STUDY_GROUP, "%s님이 새로운 풀이를 등록했습니다! 풀이를 확인하고 의견을 나눠보세요."), + NEW_MEMBER_JOINED(NotificationType.STUDY_GROUP, "%s님이 스터디에 합류했습니다!"), + NEW_MEMBER_REQUESTED(NotificationType.STUDY_GROUP, "%s님이 스터디에 합류를 요청했습니다!"), + NEW_GROUP_JOINED(NotificationType.STUDY_GROUP, "[%s] 스터디 가입 요청이 승인되었습니다."), + + NEW_COMMENT_POSTED(NotificationType.COMMENT, "%s님이 내 풀이에 코멘트를 남겼습니다! 어떤 리뷰인지 확인해보세요."); + + private final NotificationType type; private final String message; - NotificationCategory(String message) { + NotificationCategory(NotificationType type, String message) { + this.type = type; this.message = message; } + public NotificationType getType() { return type; } + public String getMessage(Object... args) { return String.format(message, args); } -} + +} \ No newline at end of file diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationType.java b/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationType.java new file mode 100644 index 00000000..a446d867 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/notification/enums/NotificationType.java @@ -0,0 +1,7 @@ +package com.gamzabat.algohub.feature.notification.enums; + +public enum NotificationType { + + PROBLEM, STUDY_GROUP, COMMENT + +} \ No newline at end of file diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/repository/NotificationRepository.java b/src/main/java/com/gamzabat/algohub/feature/notification/repository/NotificationRepository.java index 8e251362..03cfb12a 100644 --- a/src/main/java/com/gamzabat/algohub/feature/notification/repository/NotificationRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/notification/repository/NotificationRepository.java @@ -8,12 +8,16 @@ import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; import com.gamzabat.algohub.feature.notification.domain.Notification; +import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.problem.domain.Problem; import com.gamzabat.algohub.feature.user.domain.User; public interface NotificationRepository extends JpaRepository { List findAllByUser(User user); + List findAllByUserAndType(User user, NotificationType type); + List findAllByUserAndIsRead(User user, boolean isRead); @Modifying diff --git a/src/main/java/com/gamzabat/algohub/feature/notification/service/NotificationService.java b/src/main/java/com/gamzabat/algohub/feature/notification/service/NotificationService.java index 41a42437..cb767b4e 100644 --- a/src/main/java/com/gamzabat/algohub/feature/notification/service/NotificationService.java +++ b/src/main/java/com/gamzabat/algohub/feature/notification/service/NotificationService.java @@ -22,6 +22,7 @@ import com.gamzabat.algohub.feature.notification.domain.NotificationSetting; import com.gamzabat.algohub.feature.notification.dto.GetNotificationResponse; import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.exception.CannotFoundNotificationException; import com.gamzabat.algohub.feature.notification.exception.CannotFoundNotificationSettingException; import com.gamzabat.algohub.feature.notification.exception.NotificationValidationException; @@ -102,8 +103,8 @@ private void sendLostData(String lastEventId, String email, String emitterId, Ss @Transactional public void send(String receiver, String message, StudyGroup studyGroup, Problem problem, Solution solution, - String subContent) { - Notification notification = createNotification(receiver, message, studyGroup, subContent, problem, solution); + String subContent, NotificationType type) { + Notification notification = createNotification(receiver, message, studyGroup, subContent, problem, solution, type); notificationRepository.save(notification); Map sseEmitter = emitterRepository.findAllEmitterStartWithByEmail(receiver); sseEmitter.forEach( @@ -115,14 +116,14 @@ public void send(String receiver, String message, StudyGroup studyGroup, Problem } private void sendList(List receiverList, String message, StudyGroup studyGroup, String subContent, Problem problem, - Solution solution) { + Solution solution, NotificationType type) { List notifications = new ArrayList<>(); Map sseEmitters; for (int i = 0; i < receiverList.size(); i++) { int finalI = i; sseEmitters = new HashMap<>(); Notification notification = createNotification(receiverList.get(i).toString(), message, studyGroup, - subContent, problem, solution); + subContent, problem, solution, type); notifications.add(notification); notificationRepository.save(notification); sseEmitters.putAll(emitterRepository.findAllEmitterStartWithByEmail(receiverList.get(i).toString())); @@ -136,7 +137,7 @@ private void sendList(List receiverList, String message, StudyGroup studyGroup, } private Notification createNotification(String receiver, String message, StudyGroup studyGroup, String subContent, - Problem problem, Solution solution) { + Problem problem, Solution solution, NotificationType type) { return Notification.builder() .user( userRepository.findByEmail(receiver).orElseThrow(() -> new UserValidationException("존재 하지 않는 회원 입니다."))) @@ -146,6 +147,7 @@ private Notification createNotification(String receiver, String message, StudyGr .solution(solution) .subContent(subContent) .isRead(false) + .type(type) .build(); } @@ -162,8 +164,14 @@ private void sendToClient(SseEmitter emitter, String id, Notification notificati } @Transactional(readOnly = true) - public List getNotifications(User user) { - List notifications = notificationRepository.findAllByUser(user); + public List getNotifications(User user, NotificationType type) { + List notifications; + if (type == null) { + notifications = notificationRepository.findAllByUser(user); + } else { + notifications = notificationRepository.findAllByUserAndType(user, type); + + } notifications.sort(Comparator.comparingLong(Notification::getId).reversed()); return notifications.stream().map(GetNotificationResponse::toDTO).toList(); } @@ -188,7 +196,7 @@ public void readNotification(User user, Long notificationId) { @Transactional public void sendNotificationToMembers(StudyGroup group, List receiver, Problem problem, Solution solution, - NotificationCategory category, String message) { + NotificationCategory category, String message, NotificationType type) { List users = new ArrayList<>(); for (GroupMember member : receiver) { NotificationSetting setting = notificationSettingRepository.findByMember(member) @@ -203,7 +211,7 @@ public void sendNotificationToMembers(StudyGroup group, List receiv } try { - sendList(users, message, group, null, problem, solution); + sendList(users, message, group, null, problem, solution, type); } catch (Exception e) { log.warn("failed to send notification", e); } @@ -216,6 +224,8 @@ private boolean isSettingOn(NotificationSetting setting, NotificationCategory ca case NotificationCategory.NEW_COMMENT_POSTED -> setting.isNewComment(); case NotificationCategory.NEW_MEMBER_JOINED -> setting.isNewMember(); case NotificationCategory.NEW_SOLUTION_POSTED -> setting.isNewSolution(); + case NotificationCategory.NEW_MEMBER_REQUESTED -> setting.isNewMember(); + case NotificationCategory.NEW_GROUP_JOINED -> setting.isNewMember(); }; } diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/controller/ProblemController.java b/src/main/java/com/gamzabat/algohub/feature/problem/controller/ProblemController.java index 9bd25039..4b3f23ed 100644 --- a/src/main/java/com/gamzabat/algohub/feature/problem/controller/ProblemController.java +++ b/src/main/java/com/gamzabat/algohub/feature/problem/controller/ProblemController.java @@ -6,7 +6,10 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.parameters.P; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -23,6 +26,7 @@ import com.gamzabat.algohub.feature.problem.dto.CreateProblemRequest; import com.gamzabat.algohub.feature.problem.dto.EditProblemRequest; import com.gamzabat.algohub.feature.problem.dto.GetProblemResponse; +import com.gamzabat.algohub.feature.problem.enums.ProblemListStatus; import com.gamzabat.algohub.feature.problem.service.ProblemService; import com.gamzabat.algohub.feature.user.domain.User; @@ -59,26 +63,17 @@ public ResponseEntity editProblemDeadline(@AuthedUser User user, return ResponseEntity.ok().build(); } - @GetMapping(value = "/groups/{groupId}/problems/in-progress") - @Operation(summary = "진행 중인 문제 목록 조회 API", description = "특정 그룹에 대한 문제를 모두 조회하는 API") - public ResponseEntity> getInProgressProblemList(@AuthedUser User user, + @GetMapping(value = "/groups/{groupId}/problems") + @Operation(summary = "문제 목록 조회 API", description = "특정 그룹에 대한 문제를 부분 조회하는 API") + public ResponseEntity> getProblems(@AuthedUser User user, @PathVariable Long groupId, - @RequestParam(name = "unsolved-only") Boolean unsolvedOnly, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(PROBLEM_SORT_BY).descending()); - Page response = problemService.getInProgressProblems(user, groupId, unsolvedOnly, pageable); - return ResponseEntity.ok().body(response); - } + @RequestParam ProblemListStatus status, + @RequestParam(name = "unsolved-only", required = false) Boolean unsolvedOnly, + @PageableDefault(page = 0, size = 20, sort = "endDate", direction = Sort.Direction.DESC) + Pageable pageable) { + + Page response = problemService.getProblems(user, groupId, status, unsolvedOnly, pageable); - @GetMapping(value = "/groups/{groupId}/problems/expired") - @Operation(summary = "마감 된 문제 목록 조회 API", description = "특정 그룹에 대한 문제를 모두 조회하는 API") - public ResponseEntity> getExpiredProblemList(@AuthedUser User user, - @PathVariable Long groupId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(PROBLEM_SORT_BY).descending()); - Page response = problemService.getExpiredProblems(user, groupId, pageable); return ResponseEntity.ok().body(response); } @@ -89,23 +84,6 @@ public ResponseEntity getProblem(@AuthedUser User user, @Pat return ResponseEntity.ok().body(response); } - @GetMapping("/groups/{groupId}/problems/deadline-reached") - @Operation(summary = "마감 기한이 내일까지인 문제들 조회 API") - public ResponseEntity> getDeadlineReachedProblemList(@AuthedUser User user, - @PathVariable Long groupId) { - return ResponseEntity.ok().body(problemService.getDeadlineReachedProblemList(user, groupId)); - } - - @GetMapping("/groups/{groupId}/problems/queued") - @Operation(summary = "시작 예정인 문제들 조회 API") - public ResponseEntity> getQueuedProblemList(@AuthedUser User user, - @PathVariable Long groupId, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by(PROBLEM_SORT_BY).descending()); - return ResponseEntity.ok().body(problemService.getQueuedProblems(user, groupId, pageable)); - } - @DeleteMapping(value = "/problems/{problemId}") @Operation(summary = "문제 삭제 API") public ResponseEntity deleteProblem(@AuthedUser User user, @PathVariable Long problemId) { diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/domain/Problem.java b/src/main/java/com/gamzabat/algohub/feature/problem/domain/Problem.java index 08bdb076..e6d1c141 100644 --- a/src/main/java/com/gamzabat/algohub/feature/problem/domain/Problem.java +++ b/src/main/java/com/gamzabat/algohub/feature/problem/domain/Problem.java @@ -13,8 +13,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -23,6 +25,12 @@ @Getter @NoArgsConstructor @SQLDelete(sql = "UPDATE problem SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@Table( + name = "problem", + indexes = { + @Index(name = "idx_problem_group", columnList = "study_group_id") + } +) public class Problem { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/enums/ProblemListStatus.java b/src/main/java/com/gamzabat/algohub/feature/problem/enums/ProblemListStatus.java new file mode 100644 index 00000000..da4a1859 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/problem/enums/ProblemListStatus.java @@ -0,0 +1,8 @@ +package com.gamzabat.algohub.feature.problem.enums; + +public enum ProblemListStatus { + IN_PROGRESS, + EXPIRED, + QUEUED + +} diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java b/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java index 48bdd92b..1d69fb70 100644 --- a/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/problem/repository/ProblemRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; import com.gamzabat.algohub.feature.problem.domain.Problem; @@ -62,4 +63,8 @@ AND EXISTS ( @Modifying @Query("update Problem p set p.deletedAt = CURRENT_TIMESTAMP where p.studyGroup = :studyGroup") void deleteAllByStudyGroup(StudyGroup studyGroup); + + @Query("SELECT p FROM Problem p WHERE p.studyGroup = :studyGroup AND p.startDate BETWEEN :start AND :end AND p.deletedAt IS NULL") + List findAllByStudyGroupAndStartDateBetween(@Param("studyGroup") StudyGroup studyGroup, + @Param("start") LocalDate start, @Param("end") LocalDate end); } diff --git a/src/main/java/com/gamzabat/algohub/feature/problem/service/ProblemService.java b/src/main/java/com/gamzabat/algohub/feature/problem/service/ProblemService.java index 9f75d26a..3ecd1e90 100644 --- a/src/main/java/com/gamzabat/algohub/feature/problem/service/ProblemService.java +++ b/src/main/java/com/gamzabat/algohub/feature/problem/service/ProblemService.java @@ -3,8 +3,8 @@ import static com.gamzabat.algohub.constants.ApiConstants.*; import java.time.LocalDate; -import java.util.Comparator; import java.util.List; +import java.util.Objects; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -13,6 +13,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import com.fasterxml.jackson.core.JsonProcessingException; @@ -29,12 +30,14 @@ import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.repository.NotificationRepository; import com.gamzabat.algohub.feature.notification.service.NotificationService; import com.gamzabat.algohub.feature.problem.domain.Problem; import com.gamzabat.algohub.feature.problem.dto.CreateProblemRequest; import com.gamzabat.algohub.feature.problem.dto.EditProblemRequest; import com.gamzabat.algohub.feature.problem.dto.GetProblemResponse; +import com.gamzabat.algohub.feature.problem.enums.ProblemListStatus; import com.gamzabat.algohub.feature.problem.exception.NotBojLinkException; import com.gamzabat.algohub.feature.problem.exception.SolvedAcApiErrorException; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; @@ -73,6 +76,10 @@ public void createProblem(User user, Long groupId, CreateProblemRequest request) int level = getProblemLevel(apiResult); String title = getProblemTitle(apiResult); + if (Objects.nonNull(request.startDate())) { + checkProblemStartDate(request); + } + Problem problem = problemRepository.save(Problem.builder() .studyGroup(group) .link(request.link()) @@ -90,7 +97,8 @@ public void createProblem(User user, Long groupId, CreateProblemRequest request) problem, null, NotificationCategory.PROBLEM_STARTED, - NotificationCategory.PROBLEM_STARTED.getMessage(title) + NotificationCategory.PROBLEM_STARTED.getMessage(title), + NotificationType.PROBLEM ); log.info("success to create problem user_id={} , group_id = {}", user.getId(), groupId); @@ -112,7 +120,7 @@ public void editProblem(User user, Long problemId, EditProblemRequest request) { checkProblemValidation(problem); if (request.startDate() != null) { - checkProblemStartDate(request, problem); + // checkProblemStartDate(request, problem); problem.editProblemStartDate(request.startDate()); } if (request.endDate() != null) { @@ -139,15 +147,32 @@ private void checkProblemEndDate(EditProblemRequest request, Problem problem) { "문제 마감 날짜는 오늘 이전의 날짜로 수정할 수 없습니다."); } - private void checkProblemStartDate(EditProblemRequest request, Problem problem) { + private void checkProblemStartDate(CreateProblemRequest request) { if (request.startDate().isBefore(LocalDate.now())) throw new ProblemValidationException(HttpStatus.BAD_REQUEST.value(), "문제 시작 날짜는 오늘 이전의 날짜로 수정할 수 없습니다."); - if (request.startDate().isAfter(problem.getEndDate())) + if (request.startDate().isAfter(request.endDate())) throw new ProblemValidationException(HttpStatus.BAD_REQUEST.value(), "문제 시작 날짜는 마감 날짜 이후로 수정할 수 없습니다."); } + @Transactional(readOnly = true) + public Page getProblems(User user, Long groupId, ProblemListStatus status, Boolean unsolvedOnly, + Pageable pageable) { + Page response; + if (status == ProblemListStatus.IN_PROGRESS) { + if (unsolvedOnly == null) { + unsolvedOnly = false; + } + response = getInProgressProblems(user, groupId, unsolvedOnly, pageable); + } else if (status == ProblemListStatus.EXPIRED) { + response = getExpiredProblems(user, groupId, pageable); + } else { + response = getQueuedProblems(user, groupId, pageable); + } + return response; + } + @Transactional(readOnly = true) public Page getInProgressProblems(User user, Long groupId, Boolean unsolvedOnly, Pageable pageable) { @@ -213,37 +238,6 @@ public void deleteProblem(User user, Long problemId) { log.info("success to delete problem user_id={} , problem_id = {}", user.getId(), problemId); } - @Transactional(readOnly = true) - public List getDeadlineReachedProblemList(User user, Long groupId) { - StudyGroup group = getGroup(groupId); - if (!groupMemberRepository.existsByUserAndStudyGroup(user, group)) - throw new ProblemValidationException(HttpStatus.FORBIDDEN.value(), "문제를 조회할 권한이 없습니다."); - - List problems = problemRepository.findAllByStudyGroupAndEndDateBetween(group, LocalDate.now(), - LocalDate.now().plusDays(1)); - problems.sort(Comparator.comparing(Problem::getEndDate)); - - return problems.stream().map(problem -> { - Integer correctCount = solutionRepository.countDistinctUsersWithCorrectSolutionsByProblemId(problem.getId(), - BOJResultConstants.CORRECT); - Integer submitMemberCount = solutionRepository.countDistinctUsersByProblem(problem); - Integer groupMemberCount = groupMemberRepository.countMembersByStudyGroup(group); - Integer accuracy = calculateAccuracy(submitMemberCount, correctCount); - - return new GetProblemResponse( - problem.getTitle(), - problem.getId(), - problem.getLink(), - problem.getStartDate(), - problem.getEndDate(), - problem.getLevel(), - solutionRepository.existsByUserAndProblemAndResult(user, problem, BOJResultConstants.CORRECT), - submitMemberCount, - groupMemberCount, - accuracy); - }).toList(); - } - @Transactional(readOnly = true) public Page getQueuedProblems(User user, Long groupId, Pageable pageable) { StudyGroup group = getGroup(groupId); @@ -321,7 +315,8 @@ private void notifyProblemStartsToday(LocalDate now) { problem, null, NotificationCategory.PROBLEM_STARTED, - NotificationCategory.PROBLEM_STARTED.getMessage(problem.getTitle()) + NotificationCategory.PROBLEM_STARTED.getMessage(problem.getTitle()), + NotificationType.PROBLEM ); } } @@ -335,7 +330,8 @@ private void notifyProblemEndsToday(LocalDate now) { problem, null, NotificationCategory.PROBLEM_DEADLINE_REACHED, - NotificationCategory.PROBLEM_DEADLINE_REACHED.getMessage(problem.getTitle()) + NotificationCategory.PROBLEM_DEADLINE_REACHED.getMessage(problem.getTitle()), + NotificationType.PROBLEM ); } } @@ -350,7 +346,7 @@ private StudyGroup getGroup(Long id) { .orElseThrow(() -> new StudyGroupValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹 입니다.")); } - private JsonNode fetchProblemDetails(String problemId) { + public JsonNode fetchProblemDetails(String problemId) { String url = SOLVED_AC_PROBLEM_API_URL + problemId; try { @@ -374,6 +370,10 @@ private JsonNode fetchProblemDetails(String problemId) { throw new SolvedAcApiErrorException(HttpStatus.BAD_REQUEST.value(), "백준에 유효하지 않은 문제입니다."); return root.get(0); + } catch (ResourceAccessException e) { + log.error("Timeout or connection failed solved.ac API error : " + e.getMessage()); + throw new SolvedAcApiErrorException(HttpStatus.GATEWAY_TIMEOUT.value(), + "solved.ac API 응답이 지연되거나 연결에 실패했습니다. 잠시 후 다시 시도해주세요."); } catch (JsonProcessingException e) { log.error("Json processing error : " + e.getMessage()); throw new SolvedAcApiErrorException(HttpStatus.INTERNAL_SERVER_ERROR.value(), @@ -381,18 +381,18 @@ private JsonNode fetchProblemDetails(String problemId) { } } - private int getProblemLevel(JsonNode problemDetails) { + public int getProblemLevel(JsonNode problemDetails) { return problemDetails.get("level").asInt(); } - private String getProblemTitle(JsonNode problemDetails) { + public String getProblemTitle(JsonNode problemDetails) { return problemDetails.get("titleKo").asText(); } private String getProblemId(CreateProblemRequest request) { String url = request.link(); String[] parts = url.split("/"); - if (parts.length < 3 || !parts[2].equals(BOJ_PROBLEM_URL)) + if (parts.length < 3 || !parts[2].equals(BOJ_URL)) throw new NotBojLinkException(HttpStatus.BAD_REQUEST.value(), "백준 링크가 아닙니다"); return parts[parts.length - 1]; } diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationController.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationController.java new file mode 100644 index 00000000..9179ccba --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationController.java @@ -0,0 +1,33 @@ +package com.gamzabat.algohub.feature.recommendation.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.gamzabat.algohub.common.annotation.AuthedUser; +import com.gamzabat.algohub.feature.recommendation.dto.HomeRecommendationsResponse; +import com.gamzabat.algohub.feature.recommendation.service.RecommendationService; +import com.gamzabat.algohub.feature.user.domain.User; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +@Tag(name = "추천 API", description = "홈 화면 추천 스터디 API") +public class RecommendationController { + + private final RecommendationService recommendationService; + + @GetMapping("/home/recommendations") + @Operation(summary = "홈 추천 스터디 조회 API") + public ResponseEntity getHomeRecommendations( + @AuthedUser User user + ) { + HomeRecommendationsResponse response = recommendationService.getHomeRecommendations(user.getId()); + return ResponseEntity.ok().body(response); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeekly.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeekly.java new file mode 100644 index 00000000..38df75fa --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeekly.java @@ -0,0 +1,69 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import java.time.LocalDateTime; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "group_activity_weekly") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class GroupActivityWeekly { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_group_id", nullable = false) + private StudyGroup studyGroup; + + @Column(name = "week_start", nullable = false) + private LocalDateTime weekStart; + + @Column(name = "week_end", nullable = false) + private LocalDateTime weekEnd; + + @Column(name = "submissions", nullable = false) + private Integer submissions; + + @Column(name = "comments", nullable = false) + private Integer comments; + + @Column(name = "active_score", nullable = false) + private Double activeScore; + + @Builder + public GroupActivityWeekly( + @NotNull StudyGroup studyGroup, + @NotNull LocalDateTime weekStart, + @NotNull LocalDateTime weekEnd, + @NotNull Integer submissions, + @NotNull Integer comments, + @NotNull Double activeScore) { + this.studyGroup = studyGroup; + this.weekStart = weekStart; + this.weekEnd = weekEnd; + this.submissions = submissions; + this.comments = comments; + this.activeScore = activeScore; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupDifficultyMonthlyRolling.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupDifficultyMonthlyRolling.java new file mode 100644 index 00000000..669df3d3 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupDifficultyMonthlyRolling.java @@ -0,0 +1,57 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import java.time.LocalDateTime; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "group_difficulty_monthly_rolling") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GroupDifficultyMonthlyRolling { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_group_id", nullable = false) + private StudyGroup studyGroup; + + @Column(name = "window_start", nullable = false) + private LocalDateTime windowStart; + + @Column(name = "window_end", nullable = false) + private LocalDateTime windowEnd; + + @Column(name = "avg_difficulty", nullable = false) + private Double avgDifficulty; + + @Builder + public GroupDifficultyMonthlyRolling( + @NotNull StudyGroup studyGroup, + @NotNull LocalDateTime windowStart, + @NotNull LocalDateTime windowEnd, + @NotNull Double avgDifficulty) { + this.studyGroup = studyGroup; + this.windowStart = windowStart; + this.windowEnd = windowEnd; + this.avgDifficulty = avgDifficulty; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRolling.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRolling.java new file mode 100644 index 00000000..1d6c5c74 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRolling.java @@ -0,0 +1,67 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import java.time.LocalDateTime; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "group_join_monthly_rolling") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GroupJoinMonthlyRolling { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_group_id", nullable = false) + private StudyGroup studyGroup; + + @Column(name = "window_start", nullable = false) + private LocalDateTime windowStart; + + @Column(name = "window_end", nullable = false) + private LocalDateTime windowEnd; + + @Column(name = "new_members", nullable = false) + private Integer newMembers; + + @Column(name = "members_before_window", nullable = false) + private Integer membersBeforeWindow; + + @Column(name = "join_rate", nullable = false) + private Double joinRate; + + @Builder + public GroupJoinMonthlyRolling( + @NotNull StudyGroup studyGroup, + @NotNull LocalDateTime windowStart, + @NotNull LocalDateTime windowEnd, + @NotNull Integer newMembers, + @NotNull Integer membersBeforeWindow, + @NotNull Double joinRate) { + this.studyGroup = studyGroup; + this.windowStart = windowStart; + this.windowEnd = windowEnd; + this.newMembers = newMembers; + this.membersBeforeWindow = membersBeforeWindow; + this.joinRate = joinRate; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/StudyGroupTag.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/StudyGroupTag.java new file mode 100644 index 00000000..2d663a1f --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/StudyGroupTag.java @@ -0,0 +1,75 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import java.time.LocalDateTime; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "study_group_tag") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StudyGroupTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "study_group_id", nullable = false) + private StudyGroup studyGroup; + + @Enumerated(EnumType.STRING) + @Column(name = "tag_type", nullable = false, length = 64) + private TagType tagType; + + @Column(name = "score", nullable = false) + private Double score; + + @Column(name = "first_achieved_at", nullable = false) + private LocalDateTime firstAchievedAt; + + @Column(name = "window_start", nullable = false) + private LocalDateTime windowStart; + + @Column(name = "window_end", nullable = false) + private LocalDateTime windowEnd; + + @Column(name = "computed_at", nullable = false) + private LocalDateTime computedAt; + + @Builder + public StudyGroupTag( + @NotNull StudyGroup studyGroup, + @NotNull TagType tagType, + @NotNull Double score, + @NotNull LocalDateTime firstAchievedAt, + @NotNull LocalDateTime windowStart, + @NotNull LocalDateTime windowEnd, + @NotNull LocalDateTime computedAt) { + this.studyGroup = studyGroup; + this.tagType = tagType; + this.score = score; + this.firstAchievedAt = firstAchievedAt; + this.windowStart = windowStart; + this.windowEnd = windowEnd; + this.computedAt = computedAt; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/TagType.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/TagType.java new file mode 100644 index 00000000..ee9eb14c --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/TagType.java @@ -0,0 +1,7 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +public enum TagType { + MOST_ACTIVE_THIS_WEEK, + HIGH_JOIN_RATE_RECENT, + SIMILAR_DIFFICULTY +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/UserDifficultyMonthlyRolling.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/UserDifficultyMonthlyRolling.java new file mode 100644 index 00000000..4bef20d0 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/domain/UserDifficultyMonthlyRolling.java @@ -0,0 +1,58 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import java.time.LocalDateTime; + +import com.gamzabat.algohub.feature.user.domain.User; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "user_difficulty_monthly_rolling") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserDifficultyMonthlyRolling { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "window_start", nullable = false) + private LocalDateTime windowStart; + + @Column(name = "window_end", nullable = false) + private LocalDateTime windowEnd; + + @Column(name = "avg_difficulty", nullable = false) + private Double avgDifficulty; + + @Builder + public UserDifficultyMonthlyRolling( + @NotNull User user, + @NotNull LocalDateTime windowStart, + @NotNull LocalDateTime windowEnd, + @NotNull Double avgDifficulty) { + this.user = user; + this.windowStart = windowStart; + this.windowEnd = windowEnd; + this.avgDifficulty = avgDifficulty; + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/HomeRecommendationsResponse.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/HomeRecommendationsResponse.java new file mode 100644 index 00000000..b8527b81 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/HomeRecommendationsResponse.java @@ -0,0 +1,7 @@ +package com.gamzabat.algohub.feature.recommendation.dto; + +public record HomeRecommendationsResponse( + RecommendationItemDto mostActiveThisWeek, + RecommendationItemDto highJoinRateRecent, + RecommendationItemDto similarDifficulty +) {} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/RecommendationItemDto.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/RecommendationItemDto.java new file mode 100644 index 00000000..9c199ac4 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/RecommendationItemDto.java @@ -0,0 +1,10 @@ +package com.gamzabat.algohub.feature.recommendation.dto; + +import com.gamzabat.algohub.feature.recommendation.domain.TagType; + +public record RecommendationItemDto( + TagType tagType, + Double score, + StudyGroupSummaryDto studyGroup +) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/StudyGroupSummaryDto.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/StudyGroupSummaryDto.java new file mode 100644 index 00000000..0cc98eb8 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/dto/StudyGroupSummaryDto.java @@ -0,0 +1,16 @@ +package com.gamzabat.algohub.feature.recommendation.dto; + +import java.time.LocalDate; +import java.util.List; + +public record StudyGroupSummaryDto( + Long id, + String name, + String introduction, + String groupImage, + List tags, + LocalDate startDate, + LocalDate endDate +) { +} + diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupActivityWeeklyRepository.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupActivityWeeklyRepository.java new file mode 100644 index 00000000..9ef7966c --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupActivityWeeklyRepository.java @@ -0,0 +1,8 @@ +package com.gamzabat.algohub.feature.recommendation.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.recommendation.domain.GroupActivityWeekly; + +public interface GroupActivityWeeklyRepository extends JpaRepository { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupDifficultyMonthlyRollingRepository.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupDifficultyMonthlyRollingRepository.java new file mode 100644 index 00000000..a5140870 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupDifficultyMonthlyRollingRepository.java @@ -0,0 +1,24 @@ +package com.gamzabat.algohub.feature.recommendation.repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.gamzabat.algohub.feature.recommendation.domain.GroupDifficultyMonthlyRolling; + +public interface GroupDifficultyMonthlyRollingRepository extends JpaRepository { + + @Query(value = """ + SELECT * + FROM group_difficulty_monthly_rolling g + WHERE g.window_start = :windowStart AND g.window_end = :windowEnd + ORDER BY ABS(g.avg_difficulty - :userAvg) ASC + LIMIT 1 + """, nativeQuery = true) + Optional findTopSimilarByWindow(@Param("windowStart") LocalDateTime windowStart, + @Param("windowEnd") LocalDateTime windowEnd, + @Param("userAvg") double userAvg); +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupJoinMonthlyRollingRepository.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupJoinMonthlyRollingRepository.java new file mode 100644 index 00000000..e294a571 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/GroupJoinMonthlyRollingRepository.java @@ -0,0 +1,8 @@ +package com.gamzabat.algohub.feature.recommendation.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.recommendation.domain.GroupJoinMonthlyRolling; + +public interface GroupJoinMonthlyRollingRepository extends JpaRepository { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/StudyGroupTagRepository.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/StudyGroupTagRepository.java new file mode 100644 index 00000000..6f7cd589 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/StudyGroupTagRepository.java @@ -0,0 +1,12 @@ +package com.gamzabat.algohub.feature.recommendation.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.recommendation.domain.StudyGroupTag; +import com.gamzabat.algohub.feature.recommendation.domain.TagType; + +public interface StudyGroupTagRepository extends JpaRepository { + Optional findTop1ByTagTypeOrderByScoreDescFirstAchievedAtAsc(TagType tagType); +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/UserDifficultyMonthlyRollingRepository.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/UserDifficultyMonthlyRollingRepository.java new file mode 100644 index 00000000..ea1b5dfa --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/repository/UserDifficultyMonthlyRollingRepository.java @@ -0,0 +1,11 @@ +package com.gamzabat.algohub.feature.recommendation.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.gamzabat.algohub.feature.recommendation.domain.UserDifficultyMonthlyRolling; + +public interface UserDifficultyMonthlyRollingRepository extends JpaRepository { + Optional findTop1ByUser_IdOrderByWindowEndDesc(Long userId); +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationScheduler.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationScheduler.java new file mode 100644 index 00000000..8cd80b1a --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationScheduler.java @@ -0,0 +1,42 @@ +package com.gamzabat.algohub.feature.recommendation.scheduler; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import com.gamzabat.algohub.feature.recommendation.service.RecommendationBatchService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecommendationScheduler { + + private final RecommendationBatchService recommendationBatchService; + + /** + * 매주 월요일 오전 4시 -> '이번주 가장 많이 활동한 스터디' 집계 + * cron = "초 분 시 일 월 요일" + */ + @Scheduled(cron = "0 0 4 * * MON") + public void scheduleMostActiveStudy() { + recommendationBatchService.calculateAndSaveMostActiveStudy(); + } + + /** + * 매일 오전 4시 10분 -> '최근 가입률이 높은 스터디' 집계 + */ + @Scheduled(cron = "0 10 4 * * *") + public void scheduleHighJoinRateStudy() { + recommendationBatchService.calculateAndSaveHighJoinRateStudy(); + } + + /** + * 매일 오전 4시 20분 -> '사용자 및 스터디 난이도'를 집계 + */ + @Scheduled(cron = "0 20 4 * * *") + public void scheduleDifficultyInfo() { + recommendationBatchService.calculateAndSaveDifficultyInfo(); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchService.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchService.java new file mode 100644 index 00000000..bb6b0852 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchService.java @@ -0,0 +1,210 @@ +package com.gamzabat.algohub.feature.recommendation.service; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.problem.domain.Problem; +import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; +import com.gamzabat.algohub.feature.recommendation.domain.GroupActivityWeekly; +import com.gamzabat.algohub.feature.recommendation.domain.GroupDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.GroupJoinMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.StudyGroupTag; +import com.gamzabat.algohub.feature.recommendation.domain.TagType; +import com.gamzabat.algohub.feature.recommendation.domain.UserDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.repository.GroupActivityWeeklyRepository; +import com.gamzabat.algohub.feature.recommendation.repository.GroupDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.GroupJoinMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.StudyGroupTagRepository; +import com.gamzabat.algohub.feature.recommendation.repository.UserDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.solution.repository.SolutionCommentRepository; +import com.gamzabat.algohub.feature.solution.repository.SolutionRepository; +import com.gamzabat.algohub.feature.user.domain.User; +import com.gamzabat.algohub.feature.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class RecommendationBatchService { + + // 스터디, 유저, 활동 관련 Repository + private final StudyGroupRepository studyGroupRepository; + private final UserRepository userRepository; + private final SolutionRepository solutionRepository; + private final SolutionCommentRepository solutionCommentRepository; + private final GroupMemberRepository groupMemberRepository; + private final ProblemRepository problemRepository; + + // 집계 데이터 저장용 Repository + private final GroupActivityWeeklyRepository groupActivityWeeklyRepository; + private final GroupJoinMonthlyRollingRepository groupJoinMonthlyRollingRepository; + private final GroupDifficultyMonthlyRollingRepository groupDifficultyMonthlyRollingRepository; + private final UserDifficultyMonthlyRollingRepository userDifficultyMonthlyRollingRepository; + private final StudyGroupTagRepository studyGroupTagRepository; + + /** + * '이번주 가장 많이 활동한 스터디'를 계산하고 스냅샷을 저장합니다. + */ + public void calculateAndSaveMostActiveStudy() { + log.info("Batch Job Started: MOST_ACTIVE_THIS_WEEK"); + + // 1. 집계 기간 설정 (지난주 월요일 00:00 ~ 일요일 23:59) + LocalDateTime now = LocalDateTime.now(); + LocalDateTime weekStart = now.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) + .minusWeeks(1) + .toLocalDate() + .atStartOfDay(); + LocalDateTime weekEnd = weekStart.plusWeeks(1).minusNanos(1); + + List allGroups = studyGroupRepository.findAll(); + if (allGroups.isEmpty()) { + log.warn("No study groups found. Skipping MOST_ACTIVE_THIS_WEEK calculation."); + return; + } + + // 2. 각 스터디 그룹별 활동 점수 계산 및 저장 + Optional topGroupActivity = allGroups.stream() + .map(group -> { + Long submissions = solutionRepository.countByStudyGroupAndSolvedDateTimeBetween(group, weekStart, + weekEnd); + Long comments = solutionCommentRepository.countByStudyGroupAndCreatedAtBetween(group, weekStart, + weekEnd); + double activeScore = (submissions * 1.0) + (comments * 1.5); + + return groupActivityWeeklyRepository.save(GroupActivityWeekly.builder() + .studyGroup(group) + .weekStart(weekStart) + .weekEnd(weekEnd) + .submissions(submissions.intValue()) + .comments(comments.intValue()) + .activeScore(activeScore) + .build()); + }) + .max(Comparator.comparing(GroupActivityWeekly::getActiveScore)); + + // 3. 가장 점수가 높은 스터디를 스냅샷으로 저장 + topGroupActivity.ifPresent(activity -> { + StudyGroupTag snapshot = StudyGroupTag.builder() + .studyGroup(activity.getStudyGroup()) + .tagType(TagType.MOST_ACTIVE_THIS_WEEK) + .score(activity.getActiveScore()) + .firstAchievedAt(LocalDateTime.now()) + .windowStart(weekStart) + .windowEnd(weekEnd) + .computedAt(now) + .build(); + studyGroupTagRepository.save(snapshot); + }); + } + + /** + * '최근 가입률이 높은 스터디'를 계산하고 스냅샷을 저장합니다. + */ + public void calculateAndSaveHighJoinRateStudy() { + log.info("Batch Job Started: HIGH_JOIN_RATE_RECENT"); + + // 1. 집계 기간 설정 (최근 30일) + LocalDate today = LocalDate.now(); + LocalDate thirtyDaysAgo = today.minusDays(30); + LocalDateTime windowStart = thirtyDaysAgo.atStartOfDay(); + LocalDateTime windowEnd = today.atStartOfDay().minusNanos(1); + + List allGroups = studyGroupRepository.findAll(); + if (allGroups.isEmpty()) { + return; + } + + // 2. 각 스터디 그룹별 가입률 계산 및 저장 + Optional topJoinRateGroup = allGroups.stream() + .map(group -> { + Integer membersBeforeWindow = groupMemberRepository.countByStudyGroupAndJoinDateBefore(group, + thirtyDaysAgo); + Integer newMembers = groupMemberRepository.countByStudyGroupAndJoinDateBetween(group, thirtyDaysAgo, + today.minusDays(1)); // 오늘 가입자는 제외 + double joinRate = (double)newMembers / Math.max(1, membersBeforeWindow); + + return groupJoinMonthlyRollingRepository.save(GroupJoinMonthlyRolling.builder() + .studyGroup(group) + .windowStart(windowStart) + .windowEnd(windowEnd) + .newMembers(newMembers) + .membersBeforeWindow(membersBeforeWindow) + .joinRate(joinRate) + .build()); + }) + .max(Comparator.comparing(GroupJoinMonthlyRolling::getJoinRate)); + + // 3. 가장 가입률이 높은 스터디를 스냅샷으로 저장 + topJoinRateGroup.ifPresent(joinInfo -> { + StudyGroupTag snapshot = StudyGroupTag.builder() + .studyGroup(joinInfo.getStudyGroup()) + .tagType(TagType.HIGH_JOIN_RATE_RECENT) + .score(joinInfo.getJoinRate()) + .firstAchievedAt(LocalDateTime.now()) + .windowStart(windowStart) + .windowEnd(windowEnd) + .computedAt(LocalDateTime.now()) + .build(); + studyGroupTagRepository.save(snapshot); + }); + } + + /** + * '스터디 및 사용자별 난이도'를 계산하여 저장합니다. (스냅샷 X) + */ + public void calculateAndSaveDifficultyInfo() { + log.info("Batch Job Started: SIMILAR_DIFFICULTY"); + + // 1. 집계 기간 설정 (최근 30일) + LocalDate today = LocalDate.now(); + LocalDate thirtyDaysAgo = today.minusDays(30); + LocalDateTime windowStart = thirtyDaysAgo.atStartOfDay(); + LocalDateTime windowEnd = today.atStartOfDay().minusNanos(1); + + // 2. 스터디별 평균 난이도 계산 및 저장 + List allGroups = studyGroupRepository.findAll(); + allGroups.forEach(group -> { + List problems = problemRepository.findAllByStudyGroupAndStartDateBetween(group, thirtyDaysAgo, + today); + double avgDifficulty = problems.stream() + .mapToInt(Problem::getLevel) + .average() + .orElse(0.0); + + groupDifficultyMonthlyRollingRepository.save(GroupDifficultyMonthlyRolling.builder() + .studyGroup(group) + .windowStart(windowStart) + .windowEnd(windowEnd) + .avgDifficulty(avgDifficulty) + .build()); + }); + + // 3. 사용자별 평균 난이도 계산 및 저장 + List allUsers = userRepository.findAll(); + allUsers.forEach(user -> { + Double avgDifficulty = solutionRepository.findAverageProblemLevelForUserInPeriod(user, windowStart, + windowEnd); + + userDifficultyMonthlyRollingRepository.save(UserDifficultyMonthlyRolling.builder() + .user(user) + .windowStart(windowStart) + .windowEnd(windowEnd) + .avgDifficulty(avgDifficulty) + .build()); + }); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationService.java b/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationService.java new file mode 100644 index 00000000..911516f4 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationService.java @@ -0,0 +1,106 @@ +package com.gamzabat.algohub.feature.recommendation.service; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.recommendation.domain.GroupDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.TagType; +import com.gamzabat.algohub.feature.recommendation.domain.UserDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.dto.HomeRecommendationsResponse; +import com.gamzabat.algohub.feature.recommendation.dto.RecommendationItemDto; +import com.gamzabat.algohub.feature.recommendation.dto.StudyGroupSummaryDto; +import com.gamzabat.algohub.feature.recommendation.repository.GroupDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.StudyGroupTagRepository; +import com.gamzabat.algohub.feature.recommendation.repository.UserDifficultyMonthlyRollingRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RecommendationService { + + private final StudyGroupTagRepository studyGroupTagRepository; + private final UserDifficultyMonthlyRollingRepository userDifficultyMonthlyRollingRepository; + private final GroupDifficultyMonthlyRollingRepository groupDifficultyMonthlyRollingRepository; + private final StudyGroupRepository studyGroupRepository; + + public HomeRecommendationsResponse getHomeRecommendations(Long userId) { + RecommendationItemDto mostActive = buildFromTagSnapshot(TagType.MOST_ACTIVE_THIS_WEEK) + .orElseGet(() -> fallbackFromRecentGroup(TagType.MOST_ACTIVE_THIS_WEEK)); + + RecommendationItemDto highJoin = buildFromTagSnapshot(TagType.HIGH_JOIN_RATE_RECENT) + .orElseGet(() -> fallbackFromRecentGroup(TagType.HIGH_JOIN_RATE_RECENT)); + + RecommendationItemDto similar = buildSimilarDifficulty(userId) + .orElseGet(() -> fallbackFromRecentGroup(TagType.SIMILAR_DIFFICULTY)); + + return new HomeRecommendationsResponse(mostActive, highJoin, similar); + } + + private Optional buildFromTagSnapshot(TagType tagType) { + return studyGroupTagRepository.findTop1ByTagTypeOrderByScoreDescFirstAchievedAtAsc(tagType) + .map(tag -> new RecommendationItemDto( + tagType, + tag.getScore(), + toSummary(tag.getStudyGroup(), List.of(tagType.name())) + )); + } + + private Optional buildSimilarDifficulty(Long userId) { + if (userId == null) + return Optional.empty(); + + Optional userRollingOpt = + userDifficultyMonthlyRollingRepository.findTop1ByUser_IdOrderByWindowEndDesc(userId); + + if (userRollingOpt.isEmpty()) + return Optional.empty(); + + UserDifficultyMonthlyRolling userRolling = userRollingOpt.get(); + + Optional groupOpt = + groupDifficultyMonthlyRollingRepository.findTopSimilarByWindow( + userRolling.getWindowStart(), userRolling.getWindowEnd(), userRolling.getAvgDifficulty()); + + return groupOpt.map(g -> { + double diff = Math.abs(g.getAvgDifficulty() - userRolling.getAvgDifficulty()); + StudyGroup group = g.getStudyGroup(); + return new RecommendationItemDto( + TagType.SIMILAR_DIFFICULTY, + diff, + toSummary(group, List.of(TagType.SIMILAR_DIFFICULTY.name())) + ); + }); + } + + private RecommendationItemDto fallbackFromRecentGroup(TagType tagType) { + StudyGroup group = studyGroupRepository.findAll().stream() + .sorted((a, b) -> Long.compare(b.getId(), a.getId())) + .findFirst() + .orElse(null); + + if (group == null) { + return null; + } + + return new RecommendationItemDto(tagType, 0.0, toSummary(group, List.of(tagType.name()))); + } + + private StudyGroupSummaryDto toSummary(StudyGroup group, List tags) { + return new StudyGroupSummaryDto( + group.getId(), + group.getName(), + group.getIntroduction(), + group.getGroupImage(), + tags, + group.getStartDate(), + group.getEndDate() + ); + } +} diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/controller/SolutionController.java b/src/main/java/com/gamzabat/algohub/feature/solution/controller/SolutionController.java index b84fc95e..a79159cb 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/controller/SolutionController.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/controller/SolutionController.java @@ -6,9 +6,11 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; import org.springframework.validation.Errors; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -20,8 +22,10 @@ import com.gamzabat.algohub.exception.RequestException; import com.gamzabat.algohub.feature.solution.dto.CreateSolutionRequest; import com.gamzabat.algohub.feature.solution.dto.GetCurrentSolvingStatusResponse; +import com.gamzabat.algohub.feature.solution.dto.GetMySolutionListRequest; +import com.gamzabat.algohub.feature.solution.dto.GetSolutionListRequest; import com.gamzabat.algohub.feature.solution.dto.GetSolutionResponse; -import com.gamzabat.algohub.feature.solution.dto.GetSolutionWithGroupIdResponse; +import com.gamzabat.algohub.feature.solution.enums.ProgressCategory; import com.gamzabat.algohub.feature.solution.service.SolutionService; import com.gamzabat.algohub.feature.user.domain.User; @@ -41,14 +45,9 @@ public class SolutionController { @Operation(summary = "풀이 목록 조회 API", description = "특정 문제에 대한 풀이를 모두 조회하는 API") public ResponseEntity> getSolutionList(@AuthedUser User user, @PathVariable Long problemId, - @RequestParam(required = false) String language, - @RequestParam(required = false) String result, - @RequestParam(required = false) String nickname, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size); - Page response = solutionService.getSolutionList(user, problemId, nickname, language, - result, pageable); + @ModelAttribute GetSolutionListRequest request, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page response = solutionService.getSolutionList(user, problemId,request,pageable); return ResponseEntity.ok().body(response); } @@ -69,64 +68,6 @@ public ResponseEntity createSolution(@Valid @RequestBody CreateSolutionReq return ResponseEntity.ok().build(); } - @GetMapping("/groups/{groupId}/my-solutions/in-progress") - @Operation(summary = "그룹 내 진행 중인 나의 풀이 전체 조회 API", description = "특정 그룹 내에서 진행 중인 문제에 대해 제출한 나의 풀이 모두 조회하는 API") - public ResponseEntity> getMySolutionsInGroupInProgress(@AuthedUser User user, - @PathVariable Long groupId, - @RequestParam(required = false) Integer problemNumber, - @RequestParam(required = false) String language, - @RequestParam(required = false) String result, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size); - Page response = solutionService.getMySolutionsInGroupInProgress(user, - groupId, problemNumber, language, result, pageable); - return ResponseEntity.ok().body(response); - } - - @GetMapping("/groups/{groupId}/my-solutions/expired") - @Operation(summary = "그룹 내 마감된 나의 풀이 전체 조회 API", description = "특정 그룹 내에서 마감한 문제에 대해 제출한 나의 풀이를 모두 조회하는 API") - public ResponseEntity> getMySolutionsInGroupExpired(@AuthedUser User user, - @PathVariable Long groupId, - @RequestParam(required = false) Integer problemNumber, - @RequestParam(required = false) String language, - @RequestParam(required = false) String result, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size); - Page response = solutionService.getMySolutionsInGroupExpired(user, - groupId, problemNumber, language, result, pageable); - return ResponseEntity.ok().body(response); - } - - @GetMapping("/users/my-solutions/in-progress") - @Operation(summary = "진행 중인 내 풀이 전체 조회 API", description = "그룹 상관 없이 진행 중인 문제에 대한 나의 풀이 전체를 조회하는 API") - public ResponseEntity> getMySolutionsInProgress(@AuthedUser User user, - @RequestParam(required = false) Integer problemNumber, - @RequestParam(required = false) String language, - @RequestParam(required = false) String result, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size); - Page response = solutionService.getMySolutionsInProgress(user, - problemNumber, language, result, pageable); - return ResponseEntity.ok().body(response); - } - - @GetMapping("/users/my-solutions/expired") - @Operation(summary = "마감 된 내 풀이 전체 조회 API", description = "그룹 상관 없이 나의 풀이 전체를 조회하는 API") - public ResponseEntity> getMySolutions(@AuthedUser User user, - @RequestParam(required = false) Integer problemNumber, - @RequestParam(required = false) String language, - @RequestParam(required = false) String result, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size) { - Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); - Page response = solutionService.getMySolutionsExpired(user, - problemNumber, language, result, pageable); - return ResponseEntity.ok().body(response); - } - @GetMapping("/groups/{groupId}/solutions/current-status") @Operation(summary = "풀이 현황 테이블 조회 API", description = "진행 중인 문제들에 대해 풀이 현황 테이블을 조회하는 API") public ResponseEntity> getCurrentSolvingStatus(@AuthedUser User user, @@ -134,4 +75,14 @@ public ResponseEntity> getCurrentSolvingSt List response = solutionService.getCurrentSolvingStatuses(user, groupId); return ResponseEntity.ok().body(response); } + + @GetMapping("/solutions/me") + @Operation(summary = "내 풀이 전체 조회", description = "나의 풀이를 그룹, 문제 번호, 언어, 결과, 상태 등으로 필터링하여 조회하는 API") + public ResponseEntity> getMySolutionList(@AuthedUser User user, + @ModelAttribute GetMySolutionListRequest request, + @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + Page response = solutionService.getMySolutionList(user, request.groupId(), + request.problemNumber(), request.language(), request.result(), request.status(), request.isIncorrect(), pageable); + return ResponseEntity.ok().body(response); + } } diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/domain/Solution.java b/src/main/java/com/gamzabat/algohub/feature/solution/domain/Solution.java index 54d5b0a8..6fe07c9c 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/domain/Solution.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/domain/Solution.java @@ -13,8 +13,10 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -23,6 +25,12 @@ @Getter @NoArgsConstructor @SQLDelete(sql = "UPDATE solution SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@Table( + name = "solution", + indexes = { + @Index(name = "idx_solution_problem", columnList = "problem_id") + } +) public class Solution { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/domain/SolutionComment.java b/src/main/java/com/gamzabat/algohub/feature/solution/domain/SolutionComment.java index 89f96e1a..3cce6405 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/domain/SolutionComment.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/domain/SolutionComment.java @@ -7,8 +7,10 @@ import jakarta.persistence.Entity; import jakarta.persistence.FetchType; +import jakarta.persistence.Index; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,6 +19,12 @@ @Getter @NoArgsConstructor @DynamicUpdate +@Table( + name = "solution_comment", + indexes = { + @Index(name = "idx_comment_solution_created", columnList = "solution_id,created_at") + } +) public class SolutionComment extends Comment { @ManyToOne(fetch = FetchType.LAZY) diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListRequest.java b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListRequest.java new file mode 100644 index 00000000..d89c4f9d --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListRequest.java @@ -0,0 +1,16 @@ +package com.gamzabat.algohub.feature.solution.dto; + +import org.springframework.web.bind.annotation.RequestParam; + +import com.gamzabat.algohub.feature.solution.enums.ProgressCategory; + +import lombok.Builder; + +@Builder +public record GetMySolutionListRequest(Long groupId, + Integer problemNumber, + String language, + String result, + ProgressCategory status, + Boolean isIncorrect) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListResponse.java b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListResponse.java deleted file mode 100644 index 04379803..00000000 --- a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetMySolutionListResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.gamzabat.algohub.feature.solution.dto; - -import org.springframework.data.domain.Page; - -public record GetMySolutionListResponse(Page inProgress, - Page expired) { -} diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionListRequest.java b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionListRequest.java new file mode 100644 index 00000000..5bfc3a16 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionListRequest.java @@ -0,0 +1,9 @@ +package com.gamzabat.algohub.feature.solution.dto; + +import lombok.Builder; + +@Builder +public record GetSolutionListRequest( String language, + String result, + String nickname) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionResponse.java b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionResponse.java index 090efd19..c1d62e1d 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionResponse.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionResponse.java @@ -11,6 +11,7 @@ @Getter public class GetSolutionResponse { private final Long solutionId; + private final Long problemId; private final String problemTitle; private final Integer problemLevel; private final Integer accuracy; @@ -27,11 +28,13 @@ public class GetSolutionResponse { private final Integer codeLength; private final Long commentCount; private final Boolean isRead; + private final Long groupId; public static GetSolutionResponse toDTO(Solution solution, Integer accuracy, Integer submitMemberCount, Integer totalMemberCount, Long commentCount, Boolean isRead) { return GetSolutionResponse.builder() .solutionId(solution.getId()) + .problemId(solution.getProblem().getId()) .problemTitle(solution.getProblem().getTitle()) .problemLevel(solution.getProblem().getLevel()) .accuracy(accuracy) @@ -48,6 +51,7 @@ public static GetSolutionResponse toDTO(Solution solution, Integer accuracy, Int .codeLength(solution.getCodeLength()) .commentCount(commentCount) .isRead(isRead) + .groupId(solution.getProblem().getStudyGroup().getId()) .build(); } diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionWithGroupIdResponse.java b/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionWithGroupIdResponse.java deleted file mode 100644 index 493a6229..00000000 --- a/src/main/java/com/gamzabat/algohub/feature/solution/dto/GetSolutionWithGroupIdResponse.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.gamzabat.algohub.feature.solution.dto; - -import com.gamzabat.algohub.common.DateFormatUtil; -import com.gamzabat.algohub.feature.solution.domain.Solution; - -import lombok.Getter; -import lombok.experimental.SuperBuilder; - -@SuperBuilder -@Getter -public class GetSolutionWithGroupIdResponse extends GetSolutionResponse { - private final Long groupId; - - public static GetSolutionWithGroupIdResponse toDTO(Solution solution, Integer accuracy, Integer submitMemberCount, - Integer totalMemberCount, Long commentCount, Boolean isRead) { - return GetSolutionWithGroupIdResponse.builder() - .solutionId(solution.getId()) - .problemTitle(solution.getProblem().getTitle()) - .problemLevel(solution.getProblem().getLevel()) - .accuracy(accuracy) - .submitMemberCount(submitMemberCount) - .totalMemberCount(totalMemberCount) - .nickname(solution.getUser().getNickname()) - .profileImage(solution.getUser().getProfileImage()) - .solvedDateTime(DateFormatUtil.formatDateTime(solution.getSolvedDateTime())) - .content(solution.getContent()) - .result(convertToCustomResult(solution.getResult())) - .memoryUsage(solution.getMemoryUsage()) - .executionTime(solution.getExecutionTime()) - .language(solution.getLanguage()) - .codeLength(solution.getCodeLength()) - .commentCount(commentCount) - .isRead(isRead) - .groupId(solution.getProblem().getStudyGroup().getId()) - .build(); - } -} diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/repository/SolutionCommentRepository.java b/src/main/java/com/gamzabat/algohub/feature/solution/repository/SolutionCommentRepository.java index 1f38e158..591acb11 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/repository/SolutionCommentRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/repository/SolutionCommentRepository.java @@ -1,5 +1,6 @@ package com.gamzabat.algohub.feature.solution.repository; +import java.time.LocalDateTime; import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; @@ -20,4 +21,11 @@ public interface SolutionCommentRepository extends JpaRepository, CustomSolutionRepository { @Query("SELECT s FROM Solution s " @@ -67,4 +72,26 @@ Long countDistinctCorrectSolutionsByUserAndGroup(User user, StudyGroup group, + "AND s.user = :user " + "AND s.problem = :problem") List findAllByUserAndProblem(User user, Problem problem); + + @Query("SELECT COUNT(s) FROM Solution s WHERE s.problem.studyGroup = :studyGroup AND s.solvedDateTime BETWEEN :start AND :end") + Long countByStudyGroupAndSolvedDateTimeBetween(@Param("studyGroup") StudyGroup studyGroup, + @Param("start") LocalDateTime start, @Param("end") LocalDateTime end); + + @Query("SELECT COALESCE(AVG(s.problem.level), 0.0) FROM Solution s WHERE s.user = :user AND s.solvedDateTime BETWEEN :start AND :end AND s.deletedAt IS NULL") + Double findAverageProblemLevelForUserInPeriod(@Param("user") User user, @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + @Query(""" + select distinct new com.gamzabat.algohub.feature.user.dto.GetSolutionCommentActivity(s.id, g.id) + from GroupMember gm + join gm.studyGroup g + join Problem p on p.studyGroup = g + join Solution s on s.problem = p + where gm.user = :user + and gm.isVisible = true + and exists (select 1 from SolutionComment c where c.solution = s) + """) + List findFeedSolutionsByUserOrdered(User user); + } + diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepository.java b/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepository.java index cb9c44ce..67f9061f 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepository.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepository.java @@ -34,5 +34,13 @@ Page findAllFilteredMySolutions( ProgressCategory category, Pageable pageable); + Page findAllFilteredMySolutionsIsIncorrect( + User user, + Integer problemNumber, + String language, + String result, + ProgressCategory category, + Pageable pageable); + boolean existsByUserAndProblemAndResult(User user, Problem problem, String result); } diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepositoryImpl.java b/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepositoryImpl.java index 709ea20c..5e4c4893 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepositoryImpl.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/repository/querydsl/CustomSolutionRepositoryImpl.java @@ -18,6 +18,7 @@ import com.gamzabat.algohub.feature.solution.domain.Solution; import com.gamzabat.algohub.feature.solution.enums.ProgressCategory; import com.gamzabat.algohub.feature.user.domain.User; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -71,7 +72,8 @@ public Page findAllFilteredMySolutionsInGroup(User user, StudyGroup gr .and(solution.user.eq(user))) .orderBy(solution.solvedDateTime.desc()); - addMySolutionFilters(problemNumber, language, result, category, query); + addMySolutionFiltersExceptResult(problemNumber, language, category, query); + addResultFilter(result, query); query.offset(pageable.getOffset()) .limit(pageable.getPageSize()); @@ -88,7 +90,8 @@ public Page findAllFilteredMySolutions(User user, Integer problemNumbe .and(solution.deletedAt.isNull())) .orderBy(solution.solvedDateTime.desc()); - addMySolutionFilters(problemNumber, language, result, category, query); + addMySolutionFiltersExceptResult(problemNumber, language, category, query); + addResultFilter(result, query); query.offset(pageable.getOffset()) .limit(pageable.getPageSize()); @@ -96,21 +99,47 @@ public Page findAllFilteredMySolutions(User user, Integer problemNumbe return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchOne); } - private void addMySolutionFilters(Integer problemNumber, String language, String result, ProgressCategory category, + @Override + public Page findAllFilteredMySolutionsIsIncorrect(User user, + Integer problemNumber, + String language, + String result, + ProgressCategory category, + Pageable pageable) { + JPAQuery query = queryFactory.selectFrom(solution) + .where(solution.user.eq(user) + .and(solution.deletedAt.isNull())) + .orderBy(solution.solvedDateTime.desc()); + addMySolutionFiltersExceptResult(problemNumber, language, category, query); + addResultIncorrectFilter(result, query); + query.offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + JPAQuery countQuery = solutionCountQuery(query); + return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchOne); + } + + private void addMySolutionFiltersExceptResult(Integer problemNumber, String language, ProgressCategory category, JPAQuery query) { addEndDateFilter(category, query); addProblemFilter(problemNumber, query); addLanguageFilter(language, query); - addResultFilter(result, query); } private void addEndDateFilter(ProgressCategory category, JPAQuery query) { + if (category == null) return; if (category.equals(ProgressCategory.IN_PROGRESS)) query.where(problem.endDate.goe(LocalDate.now())); else if (category.equals(ProgressCategory.EXPIRED)) query.where(problem.endDate.before(LocalDate.now())); } + private void addResultIncorrectFilter(String result, JPAQuery query) { + BooleanExpression incorrectBase = + solution.result.isNotNull() + .and(solution.result.ne(CORRECT)) + .and(solution.result.endsWith(SCORE_UNIT).not()); + query.where(incorrectBase); + } private void addResultFilter(String result, JPAQuery query) { if (result != null && !result.isBlank()) { if (result.equals(CORRECT)) diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionCommentService.java b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionCommentService.java index 112e808c..b3af1319 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionCommentService.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionCommentService.java @@ -19,6 +19,7 @@ import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.service.NotificationService; import com.gamzabat.algohub.feature.problem.domain.Problem; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; @@ -74,7 +75,8 @@ private void sendCommentNotification(User commenter, Solution solution) { null, solution, NotificationCategory.NEW_COMMENT_POSTED, - NotificationCategory.NEW_COMMENT_POSTED.getMessage(commenter.getNickname()) + NotificationCategory.NEW_COMMENT_POSTED.getMessage(commenter.getNickname()), + NotificationType.COMMENT ); } diff --git a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java index 34954801..e5078f59 100644 --- a/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java +++ b/src/main/java/com/gamzabat/algohub/feature/solution/service/SolutionService.java @@ -29,6 +29,7 @@ import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.service.NotificationService; import com.gamzabat.algohub.feature.problem.domain.Problem; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; @@ -36,8 +37,8 @@ import com.gamzabat.algohub.feature.solution.domain.SolutionComment; import com.gamzabat.algohub.feature.solution.dto.CreateSolutionRequest; import com.gamzabat.algohub.feature.solution.dto.GetCurrentSolvingStatusResponse; +import com.gamzabat.algohub.feature.solution.dto.GetSolutionListRequest; import com.gamzabat.algohub.feature.solution.dto.GetSolutionResponse; -import com.gamzabat.algohub.feature.solution.dto.GetSolutionWithGroupIdResponse; import com.gamzabat.algohub.feature.solution.dto.GetSolvingStatusPerProblemResponse; import com.gamzabat.algohub.feature.solution.enums.ProgressCategory; import com.gamzabat.algohub.feature.solution.exception.CannotFoundSolutionException; @@ -65,11 +66,10 @@ public class SolutionService { private final SolutionCommentRepository solutionCommentRepository; @Transactional(readOnly = true) - public Page getSolutionList(User user, Long problemId, String nickname, - String language, String result, Pageable pageable) { + public Page getSolutionList(User user, Long problemId, GetSolutionListRequest request, Pageable pageable) { Problem problem = problemRepository.findById(problemId) .orElseThrow(() -> new ProblemValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 문제 입니다.")); - + // Gorup Id를 받고 그거로 group에 대한 확인을 해야할듯 StudyGroup group = studyGroupRepository.findById(problem.getStudyGroup().getId()) .orElseThrow(() -> new StudyGroupValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹 입니다.")); @@ -77,7 +77,7 @@ public Page getSolutionList(User user, Long problemId, Stri throw new GroupMemberValidationException(HttpStatus.FORBIDDEN.value(), "참여하지 않은 그룹 입니다."); } - Page solutions = solutionRepository.findAllFilteredSolutions(problem, nickname, language, result, + Page solutions = solutionRepository.findAllFilteredSolutions(problem, request.nickname(), request.language(), request.result(), pageable); return solutions.map(solution -> this.getGetSolutionResponse(user, solution)); @@ -98,59 +98,27 @@ public GetSolutionResponse getSolution(User user, Long solutionId) { } } - @Transactional(readOnly = true) - public Page getMySolutionsInGroupInProgress(User user, Long groupId, Integer problemNumber, - String language, - String result, Pageable pageable) { - StudyGroup group = validateGroupAndMember(user, groupId); - - Page inProgressSolutions = solutionRepository.findAllFilteredMySolutionsInGroup(user, - group, problemNumber, language, result, ProgressCategory.IN_PROGRESS, pageable) - .map(solution -> this.getGetSolutionResponse(user, solution)); - - log.info("success to get my in-progress solutions in group {}", groupId); - return inProgressSolutions; - } @Transactional(readOnly = true) - public Page getMySolutionsInGroupExpired(User user, Long groupId, Integer problemNumber, - String language, - String result, Pageable pageable) { - StudyGroup group = validateGroupAndMember(user, groupId); - - Page expiredSolutions = solutionRepository.findAllFilteredMySolutionsInGroup(user, group, - problemNumber, language, result, ProgressCategory.EXPIRED, pageable) - .map(solution -> this.getGetSolutionResponse(user, solution)); + public Page getMySolutionList(User user, Long groupId, Integer problemNumber, String language, + String result, ProgressCategory status, Boolean isIncorrect, Pageable pageable) { + Page solutionList; + if (isIncorrect == null) + isIncorrect = false; + if (groupId != null) { + StudyGroup group = validateGroupAndMember(user, groupId); + solutionList = solutionRepository.findAllFilteredMySolutionsInGroup(user,group,problemNumber,language,result,status,pageable) + .map(solution -> this.getGetSolutionResponse(user, solution)); + } else if (isIncorrect) { + solutionList = solutionRepository.findAllFilteredMySolutionsIsIncorrect(user, problemNumber, language, result, status, pageable) + .map(solution -> this.getGetSolutionResponse(user, solution)); + } else { + solutionList = solutionRepository.findAllFilteredMySolutions(user,problemNumber,language,result,status,pageable) + .map(solution -> this.getGetSolutionResponse(user, solution)); + } - log.info("success to get my expired solutions in group {}", groupId); - return expiredSolutions; - } - - @Transactional(readOnly = true) - public Page getMySolutionsInProgress(User user, Integer problemNumber, - String language, - String result, - Pageable pageable) { - Page inProgressSolutions = solutionRepository.findAllFilteredMySolutions(user, - problemNumber, - language, - result, ProgressCategory.IN_PROGRESS, pageable) - .map(solution -> this.getGetSolutionWithGroupIdResponse(user, solution)); - log.info("success to get my in-progress solutions."); - return inProgressSolutions; - } - - @Transactional(readOnly = true) - public Page getMySolutionsExpired(User user, Integer problemNumber, String language, - String result, - Pageable pageable) { - Page expiredSolutions = solutionRepository.findAllFilteredMySolutions(user, - problemNumber, - language, - result, ProgressCategory.EXPIRED, pageable) - .map(solution -> this.getGetSolutionWithGroupIdResponse(user, solution)); - log.info("success to get my expired solutions."); - return expiredSolutions; + log.info("success to get my solutions."); + return solutionList; } @Transactional(readOnly = true) @@ -256,7 +224,8 @@ private void sendNewSolutionNotification(StudyGroup group, GroupMember solver, P problem, null, NotificationCategory.NEW_SOLUTION_POSTED, - NotificationCategory.NEW_SOLUTION_POSTED.getMessage(solver.getUser().getNickname()) + NotificationCategory.NEW_SOLUTION_POSTED.getMessage(solver.getUser().getNickname()), + NotificationType.STUDY_GROUP ); } @@ -267,21 +236,6 @@ private String convertToSolvedTimeFormat(Duration duration) { return String.format("%d:%02d", hours, minutes); } - private GetSolutionWithGroupIdResponse getGetSolutionWithGroupIdResponse(User user, Solution solution) { - Integer correctCount = getCorrectCount(solution); - Integer submitMemberCount = solutionRepository.countDistinctUsersByProblem(solution.getProblem()); - Integer totalMemberCount = groupMemberRepository.countMembersByStudyGroup(getGroup(solution)) + 1; - Integer accuracy = calculateAccuracy(submitMemberCount, correctCount); - long commentCount = commentRepository.countCommentsBySolutionId(solution.getId()); - boolean isRead = true; - - if (isMySolution(user, solution)) { - isRead = isAllCommentsRead(solution); - } - return GetSolutionWithGroupIdResponse.toDTO(solution, accuracy, submitMemberCount, totalMemberCount, - commentCount, isRead); - } - private GetSolutionResponse getGetSolutionResponse(User user, Solution solution) { Integer correctCount = getCorrectCount(solution); Integer submitMemberCount = solutionRepository.countDistinctUsersByProblem(solution.getProblem()); @@ -289,7 +243,6 @@ private GetSolutionResponse getGetSolutionResponse(User user, Solution solution) Integer accuracy = calculateAccuracy(submitMemberCount, correctCount); long commentCount = commentRepository.countCommentsBySolutionId(solution.getId()); boolean isRead = true; - if (isMySolution(user, solution)) { isRead = isAllCommentsRead(solution); } diff --git a/src/main/java/com/gamzabat/algohub/feature/user/controller/UserController.java b/src/main/java/com/gamzabat/algohub/feature/user/controller/UserController.java index ba9bbf24..5a3f55f5 100644 --- a/src/main/java/com/gamzabat/algohub/feature/user/controller/UserController.java +++ b/src/main/java/com/gamzabat/algohub/feature/user/controller/UserController.java @@ -22,6 +22,7 @@ import com.gamzabat.algohub.feature.user.dto.CheckEmailRequest; import com.gamzabat.algohub.feature.user.dto.DeleteUserRequest; import com.gamzabat.algohub.feature.user.dto.EditUserPasswordRequest; +import com.gamzabat.algohub.feature.user.dto.GetSolutionCommentActivityList; import com.gamzabat.algohub.feature.user.dto.RegisterBjNickNameRequest; import com.gamzabat.algohub.feature.user.dto.RegisterRequest; import com.gamzabat.algohub.feature.user.dto.ResetPasswordRequest; @@ -151,6 +152,13 @@ public ResponseEntity deleteUser(@AuthedUser User user, @Valid @RequestBod return ResponseEntity.ok().build(); } + @GetMapping(value = "/users/me/feed") + @Operation(summary = "내 피드 조회 API") + public ResponseEntity getMyFeed(@AuthedUser User user) { + GetSolutionCommentActivityList response = userService.getSolutionCommentActivity(user); + return ResponseEntity.ok().body(response); + } + @DeleteMapping("/auth/sign-out") @Operation(summary = "로그아웃 API") public ResponseEntity logout(HttpServletRequest request) { @@ -216,4 +224,5 @@ public ResponseEntity checkPasswordToken(@RequestParam String token) { userService.validateResetPasswordToken(token); return ResponseEntity.ok().build(); } + } diff --git a/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivity.java b/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivity.java new file mode 100644 index 00000000..b30e8c52 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivity.java @@ -0,0 +1,4 @@ +package com.gamzabat.algohub.feature.user.dto; + +public record GetSolutionCommentActivity(Long solutionId, Long groupId) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivityList.java b/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivityList.java new file mode 100644 index 00000000..06eb0aa1 --- /dev/null +++ b/src/main/java/com/gamzabat/algohub/feature/user/dto/GetSolutionCommentActivityList.java @@ -0,0 +1,9 @@ +package com.gamzabat.algohub.feature.user.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record GetSolutionCommentActivityList(List solutionCommentActivityList) { +} diff --git a/src/main/java/com/gamzabat/algohub/feature/user/dto/RegisterRequest.java b/src/main/java/com/gamzabat/algohub/feature/user/dto/RegisterRequest.java index 9ce7cf9b..bd858f71 100644 --- a/src/main/java/com/gamzabat/algohub/feature/user/dto/RegisterRequest.java +++ b/src/main/java/com/gamzabat/algohub/feature/user/dto/RegisterRequest.java @@ -3,5 +3,7 @@ import jakarta.validation.constraints.NotBlank; public record RegisterRequest(@NotBlank(message = "비밀번호는 필수 입력입니다.") String password, - @NotBlank(message = "닉네임은 필수 입력입니다.") String nickname){ + @NotBlank(message = "닉네임은 필수 입력입니다.") String nickname, + @NotBlank(message = "백준 아이디는 필수 입력입니다.") String bjNickname +) { } diff --git a/src/main/java/com/gamzabat/algohub/feature/user/service/EmailService.java b/src/main/java/com/gamzabat/algohub/feature/user/service/EmailService.java index eb02723f..617dffe8 100644 --- a/src/main/java/com/gamzabat/algohub/feature/user/service/EmailService.java +++ b/src/main/java/com/gamzabat/algohub/feature/user/service/EmailService.java @@ -26,12 +26,11 @@ @Slf4j public class EmailService { - @Value("${spring.profiles.active:dev}") - private String activeProfile; - private static final String FROM_ADDRESS = "noreply@algohub.kr"; private final JavaMailSender mailSender; private final TemplateEngine templateEngine; + @Value("${spring.profiles.active:dev}") + private String activeProfile; @Async @Retryable( diff --git a/src/main/java/com/gamzabat/algohub/feature/user/service/UserService.java b/src/main/java/com/gamzabat/algohub/feature/user/service/UserService.java index ce4f7833..bfcb12ff 100644 --- a/src/main/java/com/gamzabat/algohub/feature/user/service/UserService.java +++ b/src/main/java/com/gamzabat/algohub/feature/user/service/UserService.java @@ -6,6 +6,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.Base64; +import java.util.List; import java.util.regex.Pattern; import org.springframework.http.HttpEntity; @@ -21,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.RestClient; import org.springframework.web.client.RestTemplate; import org.springframework.web.multipart.MultipartFile; @@ -35,10 +37,13 @@ import com.gamzabat.algohub.exception.UserValidationException; import com.gamzabat.algohub.feature.group.studygroup.exception.CannotFoundUserException; import com.gamzabat.algohub.feature.image.service.ImageService; +import com.gamzabat.algohub.feature.solution.repository.SolutionRepository; import com.gamzabat.algohub.feature.user.domain.ResetPassword; import com.gamzabat.algohub.feature.user.domain.User; import com.gamzabat.algohub.feature.user.dto.DeleteUserRequest; import com.gamzabat.algohub.feature.user.dto.EditUserPasswordRequest; +import com.gamzabat.algohub.feature.user.dto.GetSolutionCommentActivity; +import com.gamzabat.algohub.feature.user.dto.GetSolutionCommentActivityList; import com.gamzabat.algohub.feature.user.dto.RegisterBjNickNameRequest; import com.gamzabat.algohub.feature.user.dto.RegisterRequest; import com.gamzabat.algohub.feature.user.dto.ResetPasswordRequest; @@ -76,6 +81,8 @@ public class UserService { private final ResetPasswordRepository resetPasswordRepository; private final EmailService emailService; private final DiscordWebhookService webhookService; + private final SolutionRepository solutionRepository; + private final RestClient.Builder builder; @Transactional public void register(RegisterRequest request, MultipartFile profileImage, String token) { @@ -84,6 +91,7 @@ public void register(RegisterRequest request, MultipartFile profileImage, String checkNickname(request.nickname()); checkEmailForm(email); checkPasswordForm(request.password()); + validateBjNickname(request.bjNickname()); String encodedPassword = passwordEncoder.encode(request.password()); @@ -91,6 +99,7 @@ public void register(RegisterRequest request, MultipartFile profileImage, String .email(email) .password(encodedPassword) .nickname(request.nickname()) + .bjNickname(request.bjNickname()) .role(Role.USER) .build()); @@ -335,6 +344,13 @@ public void resetPassword(ResetPasswordRequest request) { log.info("success to reset password."); } + @Transactional + public GetSolutionCommentActivityList getSolutionCommentActivity(User user) { + List solutionCommentActivityList = solutionRepository.findFeedSolutionsByUserOrdered(user); + GetSolutionCommentActivityList response = GetSolutionCommentActivityList.builder().solutionCommentActivityList(solutionCommentActivityList).build(); + return response; + } + public void validateResetPasswordToken(String token) { this.getValidatedResetPassword(token); } diff --git a/src/test/java/com/gamzabat/algohub/feature/joinRequest/JoinRequestControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/joinRequest/JoinRequestControllerTest.java new file mode 100644 index 00000000..7a247fd4 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/joinRequest/JoinRequestControllerTest.java @@ -0,0 +1,170 @@ +package com.gamzabat.algohub.feature.joinRequest; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; +import java.util.Optional; + +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gamzabat.algohub.common.jwt.TokenProvider; +import com.gamzabat.algohub.config.SpringSecurityConfig; +import com.gamzabat.algohub.enums.JoinRequestStatus; +import com.gamzabat.algohub.feature.group.studygroup.controller.JoinRequestController; +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; +import com.gamzabat.algohub.feature.group.studygroup.dto.UpdateJoinRequestStatusRequest; +import com.gamzabat.algohub.feature.group.studygroup.exception.JoinRequestException; +import com.gamzabat.algohub.feature.group.studygroup.service.JoinRequestService; +import com.gamzabat.algohub.feature.user.domain.User; +import com.gamzabat.algohub.feature.user.repository.UserRepository; + +@WebMvcTest(JoinRequestController.class) +@WithMockUser +@Import(SpringSecurityConfig.class) +class JoinRequestControllerTest { + private final String token = "token"; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private JoinRequestService joinRequestService; + @MockBean + private TokenProvider tokenProvider; + @MockBean + private UserRepository userRepository; + private User user; + + @BeforeEach + void setUp() { + user = User.builder().email("email").password("password").build(); + when(tokenProvider.getUserEmail(token)).thenReturn("email"); + when(userRepository.findByEmail("email")).thenReturn(Optional.ofNullable(user)); + } + + @Test + @DisplayName("가입 요청 성공") + void joinRequestSuccess() throws Exception { + //given + Long groupId = 10L; + willDoNothing().given(joinRequestService).joinRequest(any(User.class), eq(groupId)); + + mockMvc.perform(post("/api/groups/{groupId}/join-request", groupId) + .header("Authorization", token)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("가입 요청 실패 : 이미 요청한 그룹") + void joinRequest_fail_alreadyRequested() throws Exception { + Long groupId = 10L; + willThrow(new JoinRequestException("이미 요청한 그룹입니다.")) + .given(joinRequestService).joinRequest(any(User.class), eq(groupId)); + + mockMvc.perform(post("/api/groups/{groupId}/join-request", groupId) + .header("Authorization", token)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("가입 요청 목록 조회") + void getAllJoinRequests_success() throws Exception { + Long groupId = 10L; + // 직렬화가 비어도 배열 길이만 확인할 수 있게 더미 객체 2개 + given(joinRequestService.getAllJoinRequests(any(User.class), eq(groupId))) + .willReturn(List.of(new JoinRequest(), new JoinRequest())); + + mockMvc.perform(get("/api/groups/{groupId}/join-request", groupId) + .header("Authorization", token)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(2))); + } + + @Test + @DisplayName("요청 목록 조회 실패 : 권한 없음") + void getAllJoinRequests_fail_alreadyRequested() throws Exception { + Long groupId = 10L; + given(joinRequestService.getAllJoinRequests(any(User.class), eq(groupId))) + .willThrow(new JoinRequestException("요청 목록을 조회할 권한이 없습니다.")); + + mockMvc.perform(get("/api/groups/{groupId}/join-request", groupId) + .header("Authorization", token)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("승인 성공") + void approve_success() throws Exception { + Long requestId = 77L; + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.APPROVE); + willDoNothing().given(joinRequestService) + .updateJoinRequest(any(User.class), eq(requestId), eq(request)); + + mockMvc.perform(post("/api/join-request/{requestId}", requestId) + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("승인 실패 해당 요청 없음") + void approve_fail_requestNotFound() throws Exception { + Long groupId = 10L; + Long requestId = 999L; + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.APPROVE); + willThrow(new JoinRequestException("해당 요청이 존재하지 않습니다")) + .given(joinRequestService).updateJoinRequest(any(User.class), eq(requestId), eq(request)); + + mockMvc.perform(post("/api/join-request/{requestId}", requestId) + .header("Authorization", token)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("가입 요청 거절 성공") + void reject_success() throws Exception { + Long requestId = 77L; + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.REJECT); + + willDoNothing().given(joinRequestService) + .updateJoinRequest(any(User.class), eq(requestId), eq(request)); + + mockMvc.perform(post("/api/join-request/{requestId}", requestId) + .header("Authorization", token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("가입 요청 거절 실패 권한 없음") + void reject_fail_noPermission() throws Exception { + Long requestId = 77L; + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.REJECT); + + willThrow(new JoinRequestException("승인 권한이 없습니다.")) + .given(joinRequestService).updateJoinRequest(any(User.class), eq(requestId), eq(request)); + + mockMvc.perform(post("/api/join-request/{requestId}", requestId) + .header("Authorization", token)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/notice/service/NoticeCommentServiceTest.java b/src/test/java/com/gamzabat/algohub/feature/notice/service/NoticeCommentServiceTest.java index ea19da6d..88b7f2bc 100644 --- a/src/test/java/com/gamzabat/algohub/feature/notice/service/NoticeCommentServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/notice/service/NoticeCommentServiceTest.java @@ -132,7 +132,7 @@ void createCommentFailed_1() { assertThatThrownBy(() -> commentService.createComment(user, 10L, request)) .isInstanceOf(NoticeValidationException.class) .hasFieldOrPropertyWithValue("error", "공지사항이 존재하지 않습니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -148,7 +148,7 @@ void createCommentFailed_2() { .isInstanceOf(StudyGroupValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.NOT_FOUND.value()) .hasFieldOrPropertyWithValue("error", "스터디 그룹이 존재하지 않습니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -166,7 +166,7 @@ void createCommentFailed_3() { .isInstanceOf(GroupMemberValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.FORBIDDEN.value()) .hasFieldOrPropertyWithValue("error", "참여하지 않은 그룹 입니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test diff --git a/src/test/java/com/gamzabat/algohub/feature/notification/service/NotificationServiceTest.java b/src/test/java/com/gamzabat/algohub/feature/notification/service/NotificationServiceTest.java index 9610eb04..efdfcf47 100644 --- a/src/test/java/com/gamzabat/algohub/feature/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/notification/service/NotificationServiceTest.java @@ -20,6 +20,8 @@ import com.gamzabat.algohub.enums.Role; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; import com.gamzabat.algohub.feature.notification.domain.Notification; +import com.gamzabat.algohub.feature.notification.enums.NotificationCategory; +import com.gamzabat.algohub.feature.notification.enums.NotificationType; import com.gamzabat.algohub.feature.notification.exception.CannotFoundNotificationException; import com.gamzabat.algohub.feature.notification.exception.NotificationValidationException; import com.gamzabat.algohub.feature.notification.repository.NotificationRepository; @@ -50,8 +52,9 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { .groupImage("imageUrl") .groupCode("code") .build(); - notification1 = Notification.builder().isRead(false).studyGroup(group).user(user).message("message1").build(); - notification2 = Notification.builder().isRead(false).studyGroup(group).user(user).message("message2").build(); + notification1 = Notification.builder().isRead(false).studyGroup(group).user(user).message("message1").type( + NotificationType.STUDY_GROUP).build(); + notification2 = Notification.builder().isRead(false).studyGroup(group).user(user).message("message2").type(NotificationType.STUDY_GROUP).build(); Field userId = User.class.getDeclaredField("id"); userId.setAccessible(true); diff --git a/src/test/java/com/gamzabat/algohub/feature/problem/controller/ProblemControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/problem/controller/ProblemControllerTest.java index 59d7bb7c..391856d0 100644 --- a/src/test/java/com/gamzabat/algohub/feature/problem/controller/ProblemControllerTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/problem/controller/ProblemControllerTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.internal.matchers.Null; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; @@ -41,6 +42,7 @@ import com.gamzabat.algohub.feature.problem.dto.CreateProblemRequest; import com.gamzabat.algohub.feature.problem.dto.EditProblemRequest; import com.gamzabat.algohub.feature.problem.dto.GetProblemResponse; +import com.gamzabat.algohub.feature.problem.enums.ProblemListStatus; import com.gamzabat.algohub.feature.problem.exception.SolvedAcApiErrorException; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; import com.gamzabat.algohub.feature.problem.service.ProblemService; @@ -300,38 +302,24 @@ void editProblemDeadlineFailed_6() throws Exception { } @Test - @DisplayName("진행 중인 문제 목록 조회 성공") + @DisplayName("문제 목록 조회 성공") void getProblemList() throws Exception { // given Pageable pageable = PageRequest.of(0, 20, Sort.by("endDate").descending()); Page response = new PageImpl<>(new ArrayList<>()); - when(problemService.getInProgressProblems(any(User.class), anyLong(), eq(false), + when(problemService.getProblems(any(User.class), anyLong(), eq(ProblemListStatus.IN_PROGRESS) ,eq(false), any(Pageable.class))).thenReturn( response); // when, then - mockMvc.perform(get("/api/groups/{groupId}/problems/in-progress", groupId) + mockMvc.perform(get("/api/groups/{groupId}/problems", groupId) .header("Authorization", token) - .param("unsolved-only", String.valueOf(false))) + .param("page", "0") + .param("size", "20") + .param("unsolved-only", String.valueOf(false)) + .param("status", String.valueOf(ProblemListStatus.IN_PROGRESS))) .andExpect(status().isOk()) .andExpect(content().string(objectMapper.writeValueAsString(response))); - verify(problemService, times(1)).getInProgressProblems(user, groupId, false, pageable); - } - - @Test - @DisplayName("문제 목록 조회 실패 : 권한 없음") - void getProblemListFailed_1() throws Exception { - // given - Pageable pageable = PageRequest.of(0, 20, Sort.by("endDate").descending()); - when( - problemService.getInProgressProblems(any(User.class), anyLong(), eq(false), any(Pageable.class))).thenThrow( - new ProblemValidationException(HttpStatus.FORBIDDEN.value(), "문제를 조회할 권한이 없습니다.")); - // when, then - mockMvc.perform(get("/api/groups/{groupId}/problems/in-progress", groupId) - .header("Authorization", token) - .param("unsolved-only", String.valueOf(false))) - .andExpect(status().isForbidden()) - .andExpect(jsonPath("$.error").value("문제를 조회할 권한이 없습니다.")); - verify(problemService, times(1)).getInProgressProblems(user, groupId, false, pageable); + verify(problemService, times(1)).getProblems(user, groupId,ProblemListStatus.IN_PROGRESS,false, pageable); } @Test @@ -343,6 +331,7 @@ void deleteProblem() throws Exception { mockMvc.perform(delete("/api/problems/{problemId}", problemId) .header("Authorization", token) .param("problemId", String.valueOf(problemId))) + .andExpect(status().isOk()); verify(problemService, times(1)).deleteProblem(user, problemId); } diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationControllerTest.java new file mode 100644 index 00000000..87b0a7ea --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/controller/RecommendationControllerTest.java @@ -0,0 +1,92 @@ +package com.gamzabat.algohub.feature.recommendation.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; + +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.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gamzabat.algohub.common.jwt.TokenProvider; +import com.gamzabat.algohub.config.SpringSecurityConfig; +import com.gamzabat.algohub.feature.recommendation.domain.TagType; +import com.gamzabat.algohub.feature.recommendation.dto.HomeRecommendationsResponse; +import com.gamzabat.algohub.feature.recommendation.dto.RecommendationItemDto; +import com.gamzabat.algohub.feature.recommendation.dto.StudyGroupSummaryDto; +import com.gamzabat.algohub.feature.recommendation.service.RecommendationService; +import com.gamzabat.algohub.feature.user.domain.User; +import com.gamzabat.algohub.feature.user.repository.UserRepository; + +@WebMvcTest(controllers = RecommendationController.class) +@WithMockUser +@Import(SpringSecurityConfig.class) +class RecommendationControllerTest { + + private final String token = "token"; + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private RecommendationService recommendationService; + @MockBean + private TokenProvider tokenProvider; + @MockBean + private UserRepository userRepository; + + private User user; + + @BeforeEach + void setUp() { + user = org.mockito.Mockito.mock(User.class); + when(user.getId()).thenReturn(42L); + when(user.getEmail()).thenReturn("email"); + + when(tokenProvider.getUserEmail(token)).thenReturn("email"); + when(userRepository.findByEmail("email")).thenReturn(java.util.Optional.of(user)); + } + + @Test + @DisplayName("GET /api/home/recommendations 정상 응답") + void getHomeRecommendations_ok() throws Exception { + LocalDate startDate = LocalDate.of(2025, 1, 1); + LocalDate endDate = LocalDate.of(2025, 12, 31); + + StudyGroupSummaryDto g1 = new StudyGroupSummaryDto(1L, "A", "intro", "img", + java.util.List.of(TagType.MOST_ACTIVE_THIS_WEEK.name()), startDate, endDate); + StudyGroupSummaryDto g2 = new StudyGroupSummaryDto(2L, "B", "intro", "img", + java.util.List.of(TagType.HIGH_JOIN_RATE_RECENT.name()), startDate, endDate); + StudyGroupSummaryDto g3 = new StudyGroupSummaryDto(3L, "C", "intro", "img", + java.util.List.of(TagType.SIMILAR_DIFFICULTY.name()), startDate, endDate); + + HomeRecommendationsResponse response = new HomeRecommendationsResponse( + new RecommendationItemDto(TagType.MOST_ACTIVE_THIS_WEEK, 10.0, g1), + new RecommendationItemDto(TagType.HIGH_JOIN_RATE_RECENT, 0.3, g2), + new RecommendationItemDto(TagType.SIMILAR_DIFFICULTY, 0.1, g3) + ); + + when(recommendationService.getHomeRecommendations(any())).thenReturn(response); + + mockMvc.perform(get("/api/home/recommendations") + .header("Authorization", token) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().json(objectMapper.writeValueAsString(response))); + + verify(recommendationService, times(1)).getHomeRecommendations(any()); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeeklyTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeeklyTest.java new file mode 100644 index 00000000..83742107 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupActivityWeeklyTest.java @@ -0,0 +1,60 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +class GroupActivityWeeklyTest { + + @Test + @DisplayName("GroupActivityWeekly 엔티티 생성 성공") + void createGroupActivityWeekly_Success() { + // given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime weekStart = now.minusWeeks(1); + LocalDateTime weekEnd = now; + + StudyGroup studyGroup = StudyGroup.builder() + .name("테스트 스터디 그룹") + .build(); + + // when + GroupActivityWeekly activity = GroupActivityWeekly.builder() + .studyGroup(studyGroup) + .weekStart(weekStart) + .weekEnd(weekEnd) + .submissions(10) + .comments(5) + .activeScore(17.5) + .build(); + + // then + assertThat(activity.getStudyGroup()).isEqualTo(studyGroup); + assertThat(activity.getWeekStart()).isEqualTo(weekStart); + assertThat(activity.getWeekEnd()).isEqualTo(weekEnd); + assertThat(activity.getSubmissions()).isEqualTo(10); + assertThat(activity.getComments()).isEqualTo(5); + assertThat(activity.getActiveScore()).isEqualTo(17.5); + } + + @Test + @DisplayName("활동 점수 계산 검증") + void calculateActiveScore_Success() { + // given + GroupActivityWeekly activity = GroupActivityWeekly.builder() + .submissions(10) + .comments(5) + .activeScore(17.5) // 10 * 1.0 + 5 * 1.5 = 17.5 + .build(); + + // when & then + assertThat(activity.getActiveScore()).isEqualTo(17.5); + assertThat(activity.getSubmissions() * 1.0 + activity.getComments() * 1.5) + .isEqualTo(activity.getActiveScore()); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRollingTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRollingTest.java new file mode 100644 index 00000000..de515101 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/domain/GroupJoinMonthlyRollingTest.java @@ -0,0 +1,74 @@ +package com.gamzabat.algohub.feature.recommendation.domain; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; + +class GroupJoinMonthlyRollingTest { + + @Test + @DisplayName("GroupJoinMonthlyRolling 엔티티 생성 성공") + void createGroupJoinMonthlyRolling_Success() { + // given + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowStart = now.minusDays(30); + LocalDateTime windowEnd = now; + + StudyGroup studyGroup = StudyGroup.builder() + .name("테스트 스터디 그룹") + .build(); + + // when + GroupJoinMonthlyRolling joinInfo = GroupJoinMonthlyRolling.builder() + .studyGroup(studyGroup) + .windowStart(windowStart) + .windowEnd(windowEnd) + .newMembers(5) + .membersBeforeWindow(20) + .joinRate(0.25) + .build(); + + // then + assertThat(joinInfo.getStudyGroup()).isEqualTo(studyGroup); + assertThat(joinInfo.getWindowStart()).isEqualTo(windowStart); + assertThat(joinInfo.getWindowEnd()).isEqualTo(windowEnd); + assertThat(joinInfo.getNewMembers()).isEqualTo(5); + assertThat(joinInfo.getMembersBeforeWindow()).isEqualTo(20); + assertThat(joinInfo.getJoinRate()).isEqualTo(0.25); + } + + @Test + @DisplayName("가입률 계산 검증") + void calculateJoinRate_Success() { + // given + GroupJoinMonthlyRolling joinInfo = GroupJoinMonthlyRolling.builder() + .newMembers(5) + .membersBeforeWindow(20) + .joinRate(0.25) // 5 / 20 = 0.25 + .build(); + + // when & then + assertThat(joinInfo.getJoinRate()).isEqualTo(0.25); + assertThat((double)joinInfo.getNewMembers() / joinInfo.getMembersBeforeWindow()) + .isEqualTo(joinInfo.getJoinRate()); + } + + @Test + @DisplayName("기존 멤버가 0명일 때 가입률 계산") + void calculateJoinRate_ZeroMembersBefore() { + // given + GroupJoinMonthlyRolling joinInfo = GroupJoinMonthlyRolling.builder() + .newMembers(5) + .membersBeforeWindow(0) + .joinRate(0.0) // 5 / 0 = 0.0 + .build(); + + // when & then + assertThat(joinInfo.getJoinRate()).isEqualTo(0.0); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationSchedulerTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationSchedulerTest.java new file mode 100644 index 00000000..28fb7c51 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/scheduler/RecommendationSchedulerTest.java @@ -0,0 +1,55 @@ +package com.gamzabat.algohub.feature.recommendation.scheduler; + +import static org.mockito.Mockito.*; + +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.gamzabat.algohub.feature.recommendation.service.RecommendationBatchService; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RecommendationSchedulerTest { + + @Mock + private RecommendationBatchService recommendationBatchService; + + @InjectMocks + private RecommendationScheduler recommendationScheduler; + + @Test + @DisplayName("이번주 가장 많이 활동한 스터디 스케줄링 성공") + void scheduleMostActiveStudy_Success() { + // given & when + recommendationScheduler.scheduleMostActiveStudy(); + + // then + verify(recommendationBatchService).calculateAndSaveMostActiveStudy(); + } + + @Test + @DisplayName("최근 가입률이 높은 스터디 스케줄링 성공") + void scheduleHighJoinRateStudy_Success() { + // given & when + recommendationScheduler.scheduleHighJoinRateStudy(); + + // then + verify(recommendationBatchService).calculateAndSaveHighJoinRateStudy(); + } + + @Test + @DisplayName("난이도 정보 스케줄링 성공") + void scheduleDifficultyInfo_Success() { + // given & when + recommendationScheduler.scheduleDifficultyInfo(); + + // then + verify(recommendationBatchService).calculateAndSaveDifficultyInfo(); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchServiceTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchServiceTest.java new file mode 100644 index 00000000..51949b52 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationBatchServiceTest.java @@ -0,0 +1,215 @@ +package com.gamzabat.algohub.feature.recommendation.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDate; +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.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.problem.domain.Problem; +import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; +import com.gamzabat.algohub.feature.recommendation.domain.GroupActivityWeekly; +import com.gamzabat.algohub.feature.recommendation.domain.GroupDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.GroupJoinMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.StudyGroupTag; +import com.gamzabat.algohub.feature.recommendation.domain.UserDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.repository.GroupActivityWeeklyRepository; +import com.gamzabat.algohub.feature.recommendation.repository.GroupDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.GroupJoinMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.StudyGroupTagRepository; +import com.gamzabat.algohub.feature.recommendation.repository.UserDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.solution.repository.SolutionCommentRepository; +import com.gamzabat.algohub.feature.solution.repository.SolutionRepository; +import com.gamzabat.algohub.feature.user.domain.User; +import com.gamzabat.algohub.feature.user.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RecommendationBatchServiceTest { + + @Mock + private StudyGroupRepository studyGroupRepository; + @Mock + private UserRepository userRepository; + @Mock + private SolutionRepository solutionRepository; + @Mock + private SolutionCommentRepository solutionCommentRepository; + @Mock + private GroupMemberRepository groupMemberRepository; + @Mock + private ProblemRepository problemRepository; + @Mock + private GroupActivityWeeklyRepository groupActivityWeeklyRepository; + @Mock + private GroupJoinMonthlyRollingRepository groupJoinMonthlyRollingRepository; + @Mock + private GroupDifficultyMonthlyRollingRepository groupDifficultyMonthlyRollingRepository; + @Mock + private UserDifficultyMonthlyRollingRepository userDifficultyMonthlyRollingRepository; + @Mock + private StudyGroupTagRepository studyGroupTagRepository; + + @InjectMocks + private RecommendationBatchService recommendationBatchService; + + private StudyGroup testGroup; + private User testUser; + private Problem testProblem; + + @BeforeEach + void setUp() { + testGroup = StudyGroup.builder() + .name("테스트 스터디 그룹") + .build(); + + testUser = User.builder() + .email("test@example.com") + .password("password") + .nickname("테스트유저") + .role(com.gamzabat.algohub.enums.Role.USER) + .build(); + + testProblem = Problem.builder() + .title("테스트 문제") + .level(3) + .number(1) + .studyGroup(testGroup) + .build(); + } + + @Test + @DisplayName("이번주 가장 많이 활동한 스터디 계산 및 저장 성공") + void calculateAndSaveMostActiveStudy_Success() { + // given + List groups = List.of(testGroup); + LocalDateTime weekStart = LocalDateTime.now().minusWeeks(1).with(java.time.DayOfWeek.MONDAY); + LocalDateTime weekEnd = weekStart.plusWeeks(1).minusNanos(1); + + when(studyGroupRepository.findAll()).thenReturn(groups); + when(solutionRepository.countByStudyGroupAndSolvedDateTimeBetween(eq(testGroup), any(), any())) + .thenReturn(10L); + when(solutionCommentRepository.countByStudyGroupAndCreatedAtBetween(eq(testGroup), any(), any())) + .thenReturn(5L); + + GroupActivityWeekly savedActivity = GroupActivityWeekly.builder() + .id(1L) + .studyGroup(testGroup) + .weekStart(weekStart) + .weekEnd(weekEnd) + .submissions(10) + .comments(5) + .activeScore(17.5) + .build(); + + when(groupActivityWeeklyRepository.save(any(GroupActivityWeekly.class))).thenReturn(savedActivity); + + // when + recommendationBatchService.calculateAndSaveMostActiveStudy(); + + // then + verify(studyGroupRepository).findAll(); + verify(solutionRepository).countByStudyGroupAndSolvedDateTimeBetween(eq(testGroup), any(), any()); + verify(solutionCommentRepository).countByStudyGroupAndCreatedAtBetween(eq(testGroup), any(), any()); + verify(groupActivityWeeklyRepository).save(any(GroupActivityWeekly.class)); + verify(studyGroupTagRepository).save(any(StudyGroupTag.class)); + } + + @Test + @DisplayName("스터디 그룹이 없을 때 예외 처리") + void calculateAndSaveMostActiveStudy_NoGroups() { + // given + when(studyGroupRepository.findAll()).thenReturn(List.of()); + + // when & then + assertThatCode(() -> recommendationBatchService.calculateAndSaveMostActiveStudy()) + .doesNotThrowAnyException(); + + verify(studyGroupRepository).findAll(); + verifyNoInteractions(solutionRepository); + verifyNoInteractions(groupActivityWeeklyRepository); + verifyNoInteractions(studyGroupTagRepository); + } + + @Test + @DisplayName("최근 가입률이 높은 스터디 계산 및 저장 성공") + void calculateAndSaveHighJoinRateStudy_Success() { + // given + List groups = List.of(testGroup); + LocalDate today = LocalDate.now(); + LocalDate thirtyDaysAgo = today.minusDays(30); + + when(studyGroupRepository.findAll()).thenReturn(groups); + when(groupMemberRepository.countByStudyGroupAndJoinDateBefore(eq(testGroup), eq(thirtyDaysAgo))) + .thenReturn(20); + when(groupMemberRepository.countByStudyGroupAndJoinDateBetween(eq(testGroup), eq(thirtyDaysAgo), any())) + .thenReturn(5); + + GroupJoinMonthlyRolling savedJoinInfo = new GroupJoinMonthlyRolling( + 1L, + testGroup, + thirtyDaysAgo.atStartOfDay(), + today.atStartOfDay().minusNanos(1), + 5, + 20, + 0.25 + ); + + when(groupJoinMonthlyRollingRepository.save(any(GroupJoinMonthlyRolling.class))).thenReturn(savedJoinInfo); + + // when + recommendationBatchService.calculateAndSaveHighJoinRateStudy(); + + // then + verify(studyGroupRepository).findAll(); + verify(groupMemberRepository).countByStudyGroupAndJoinDateBefore(eq(testGroup), eq(thirtyDaysAgo)); + verify(groupMemberRepository).countByStudyGroupAndJoinDateBetween(eq(testGroup), eq(thirtyDaysAgo), any()); + verify(groupJoinMonthlyRollingRepository).save(any(GroupJoinMonthlyRolling.class)); + verify(studyGroupTagRepository).save(any(StudyGroupTag.class)); + } + + @Test + @DisplayName("난이도 정보 계산 및 저장 성공") + void calculateAndSaveDifficultyInfo_Success() { + // given + List groups = List.of(testGroup); + List users = List.of(testUser); + List problems = List.of(testProblem); + LocalDate today = LocalDate.now(); + LocalDate thirtyDaysAgo = today.minusDays(30); + + when(studyGroupRepository.findAll()).thenReturn(groups); + when(problemRepository.findAllByStudyGroupAndStartDateBetween(eq(testGroup), eq(thirtyDaysAgo), eq(today))) + .thenReturn(problems); + + when(userRepository.findAll()).thenReturn(users); + when(solutionRepository.findAverageProblemLevelForUserInPeriod(eq(testUser), any(), any())) + .thenReturn(3.5); + + // when + recommendationBatchService.calculateAndSaveDifficultyInfo(); + + // then + verify(studyGroupRepository).findAll(); + verify(problemRepository).findAllByStudyGroupAndStartDateBetween(eq(testGroup), eq(thirtyDaysAgo), eq(today)); + verify(groupDifficultyMonthlyRollingRepository).save(any(GroupDifficultyMonthlyRolling.class)); + + verify(userRepository).findAll(); + verify(solutionRepository).findAverageProblemLevelForUserInPeriod(eq(testUser), any(), any()); + verify(userDifficultyMonthlyRollingRepository).save(any(UserDifficultyMonthlyRolling.class)); + } +} diff --git a/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationServiceTest.java b/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationServiceTest.java new file mode 100644 index 00000000..de35e4ab --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/feature/recommendation/service/RecommendationServiceTest.java @@ -0,0 +1,235 @@ +package com.gamzabat.algohub.feature.recommendation.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.recommendation.domain.GroupDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.domain.StudyGroupTag; +import com.gamzabat.algohub.feature.recommendation.domain.TagType; +import com.gamzabat.algohub.feature.recommendation.domain.UserDifficultyMonthlyRolling; +import com.gamzabat.algohub.feature.recommendation.dto.HomeRecommendationsResponse; +import com.gamzabat.algohub.feature.recommendation.repository.GroupDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.recommendation.repository.StudyGroupTagRepository; +import com.gamzabat.algohub.feature.recommendation.repository.UserDifficultyMonthlyRollingRepository; +import com.gamzabat.algohub.feature.user.domain.User; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RecommendationServiceTest { + + @Mock + private StudyGroupTagRepository studyGroupTagRepository; + @Mock + private UserDifficultyMonthlyRollingRepository userDifficultyMonthlyRollingRepository; + @Mock + private GroupDifficultyMonthlyRollingRepository groupDifficultyMonthlyRollingRepository; + @Mock + private StudyGroupRepository studyGroupRepository; + + @InjectMocks + private RecommendationService recommendationService; + + private StudyGroup activeGroup1; + private StudyGroup activeGroup2; + private StudyGroup highJoinGroup; + private StudyGroup similarGroup; + + @BeforeEach + void setUp() { + activeGroup1 = createStudyGroup("활발한 스터디 1"); + activeGroup2 = createStudyGroup("활발한 스터디 2"); + highJoinGroup = createStudyGroup("인기 스터디"); + similarGroup = createStudyGroup("비슷한 난이도 스터디"); + } + + @Test + @DisplayName("이번 주 가장 활발한 스터디 조회 - 성공") + void getMostActiveThisWeek_Success() { + // given + StudyGroupTag highActivityTag = createStudyGroupTag( + activeGroup1, + TagType.MOST_ACTIVE_THIS_WEEK, + 0.9, + LocalDateTime.now().minusDays(1) + ); + + StudyGroupTag mediumActivityTag = createStudyGroupTag( + activeGroup2, + TagType.MOST_ACTIVE_THIS_WEEK, + 0.7, // 중간 활동 점수 + LocalDateTime.now().minusDays(2) + ); + + when(studyGroupTagRepository.findTop1ByTagTypeOrderByScoreDescFirstAchievedAtAsc(TagType.MOST_ACTIVE_THIS_WEEK)) + .thenReturn(Optional.of(highActivityTag)); + + // when + HomeRecommendationsResponse response = recommendationService.getHomeRecommendations(1L); + + // then + Assertions.assertAll( + () -> assertThat(response.mostActiveThisWeek()).isNotNull(), + () -> assertThat(response.mostActiveThisWeek().studyGroup().id()).isEqualTo(activeGroup1.getId()), + () -> assertThat(response.mostActiveThisWeek().studyGroup().name()).isEqualTo(activeGroup1.getName()), + () -> assertThat(response.mostActiveThisWeek().score()).isEqualTo(0.9) + ); + } + + @Test + @DisplayName("최근 가입률이 높은 스터디 조회 - 성공") + void getHighJoinRateRecent_Success() { + // given + StudyGroupTag highJoinTag = createStudyGroupTag( + highJoinGroup, + TagType.HIGH_JOIN_RATE_RECENT, + 0.85, // 가장 높은 가입률 + LocalDateTime.now().minusDays(1) + ); + + StudyGroupTag mediumJoinTag = createStudyGroupTag( + createStudyGroup("중간 인기 스터디"), + TagType.HIGH_JOIN_RATE_RECENT, + 0.65, // 중간 가입률 + LocalDateTime.now().minusDays(2) + ); + + when(studyGroupTagRepository.findTop1ByTagTypeOrderByScoreDescFirstAchievedAtAsc(TagType.HIGH_JOIN_RATE_RECENT)) + .thenReturn(Optional.of(highJoinTag)); + + // when + HomeRecommendationsResponse response = recommendationService.getHomeRecommendations(1L); + + // then + Assertions.assertAll( + () -> assertThat(response.highJoinRateRecent()).isNotNull(), + () -> assertThat(response.highJoinRateRecent().studyGroup().id()).isEqualTo(highJoinGroup.getId()), + () -> assertThat(response.highJoinRateRecent().studyGroup().name()).isEqualTo(highJoinGroup.getName()), + () -> assertThat(response.highJoinRateRecent().score()).isEqualTo(0.85) + ); + } + + @Test + @DisplayName("비슷한 난이도 스터디 추천 - 성공") + void getSimilarDifficulty_Success() { + // given + Long userId = 1L; + + User testUser = mock(User.class); + when(testUser.getId()).thenReturn(userId); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowStart = now.minusDays(30); + LocalDateTime windowEnd = now; + + // 사용자 난이도 설정 + UserDifficultyMonthlyRolling userRolling = createUserDifficultyMonthlyRolling( + testUser, windowStart, windowEnd, 0.7); + + // 비슷한 난이도의 스터디 그룹 (가장 유사한 그룹) + GroupDifficultyMonthlyRolling similarGroupRolling = createGroupDifficultyMonthlyRolling( + similarGroup, windowStart, windowEnd, 0.72); // 사용자와 가장 가까운 난이도 + + // 덜 비슷한 난이도의 스터디 그룹 + GroupDifficultyMonthlyRolling lessSimilarGroupRolling = createGroupDifficultyMonthlyRolling( + createStudyGroup("덜 비슷한 난이도"), windowStart, windowEnd, 0.85); + + when(userDifficultyMonthlyRollingRepository.findTop1ByUser_IdOrderByWindowEndDesc(userId)) + .thenReturn(Optional.of(userRolling)); + + when(groupDifficultyMonthlyRollingRepository.findTopSimilarByWindow( + eq(windowStart), eq(windowEnd), eq(0.7))) + .thenReturn(Optional.of(similarGroupRolling)); + + // when + HomeRecommendationsResponse response = recommendationService.getHomeRecommendations(userId); + + // then + Assertions.assertAll( + () -> assertThat(response.similarDifficulty()).isNotNull(), + () -> assertThat(response.similarDifficulty().studyGroup().id()).isEqualTo(similarGroup.getId()), + () -> assertThat(response.similarDifficulty().studyGroup().name()).isEqualTo(similarGroup.getName()), + () -> assertThat(response.similarDifficulty().score()).isBetween(0.0, 0.1) // 0.72 - 0.7 = 0.02 차이 + ); + } + + @Test + @DisplayName("추천 데이터가 없을 때 기본값 반환") + void getHomeRecommendations_WhenNoData_ThenReturnDefault() { + // given + when(studyGroupTagRepository.findTop1ByTagTypeOrderByScoreDescFirstAchievedAtAsc(any())) + .thenReturn(Optional.empty()); + when(userDifficultyMonthlyRollingRepository.findTop1ByUser_IdOrderByWindowEndDesc(any())) + .thenReturn(Optional.empty()); + when(studyGroupRepository.findAll()).thenReturn(List.of()); + + // when + HomeRecommendationsResponse response = recommendationService.getHomeRecommendations(1L); + + // then + Assertions.assertAll( + () -> assertThat(response).isNotNull(), + () -> assertThat(response.mostActiveThisWeek()).isNull(), + () -> assertThat(response.highJoinRateRecent()).isNull(), + () -> assertThat(response.similarDifficulty()).isNull() + ); + } + + // Helper methods + private StudyGroup createStudyGroup(String name) { + return StudyGroup.builder() + .name(name) + .introduction(name + "소개") + .groupImage("image_url_" + name + ".jpg") + .build(); + } + + private StudyGroupTag createStudyGroupTag(StudyGroup group, TagType tagType, double score, + LocalDateTime firstAchievedAt) { + return StudyGroupTag.builder() + .studyGroup(group) + .tagType(tagType) + .score(score) + .firstAchievedAt(firstAchievedAt) + .windowStart(firstAchievedAt.toLocalDate().withDayOfMonth(1).atStartOfDay()) + .windowEnd(firstAchievedAt.toLocalDate().withDayOfMonth(1).plusMonths(1).atStartOfDay()) + .computedAt(firstAchievedAt) + .build(); + } + + private UserDifficultyMonthlyRolling createUserDifficultyMonthlyRolling( + User user, LocalDateTime windowStart, LocalDateTime windowEnd, double avgDifficulty) { + return UserDifficultyMonthlyRolling.builder() + .user(user) + .windowStart(windowStart) + .windowEnd(windowEnd) + .avgDifficulty(avgDifficulty) + .build(); + } + + private GroupDifficultyMonthlyRolling createGroupDifficultyMonthlyRolling( + StudyGroup group, LocalDateTime windowStart, LocalDateTime windowEnd, double avgDifficulty) { + return GroupDifficultyMonthlyRolling.builder() + .studyGroup(group) + .windowStart(windowStart) + .windowEnd(windowEnd) + .avgDifficulty(avgDifficulty) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/com/gamzabat/algohub/feature/solution/controller/SolutionControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/solution/controller/SolutionControllerTest.java index 7ade4acf..1c681246 100644 --- a/src/test/java/com/gamzabat/algohub/feature/solution/controller/SolutionControllerTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/solution/controller/SolutionControllerTest.java @@ -34,6 +34,7 @@ import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; +import com.gamzabat.algohub.feature.solution.dto.GetSolutionListRequest; import com.gamzabat.algohub.feature.solution.dto.GetSolutionResponse; import com.gamzabat.algohub.feature.solution.exception.CannotFoundSolutionException; import com.gamzabat.algohub.feature.solution.repository.SolutionCommentRepository; @@ -87,46 +88,63 @@ void getSolutionList_1() throws Exception { Pageable pageable = PageRequest.of(0, 20); GetSolutionResponse response = GetSolutionResponse.builder().build(); Page pagedResponse = new PageImpl<>(Collections.singletonList(response), pageable, 1); - when(solutionService.getSolutionList(any(User.class), anyLong(), isNull(), isNull(), isNull(), + when(solutionService.getSolutionList(any(User.class), anyLong(),any(GetSolutionListRequest.class), any(Pageable.class))).thenReturn(pagedResponse); // when, then mockMvc.perform(get("/api/problems/{problemId}/solutions", problemId) .header("Authorization", token)) .andExpect(status().isOk()) .andExpect(content().string(objectMapper.writeValueAsString(pagedResponse))); - verify(solutionService, times(1)).getSolutionList(user, problemId, null, null, null, pageable); + verify(solutionService, times(1)).getSolutionList( + any(User.class), + eq(problemId), + any(GetSolutionListRequest.class), + argThat(p -> p.getPageNumber()==0 && p.getPageSize()==20) + ); } @Test @DisplayName("풀이 목록 조회 실패 : 존재하지 않는 문제") void getSolutionListFailed_1() throws Exception { // given - Pageable pageable = PageRequest.of(0, 20); - when(solutionService.getSolutionList(any(User.class), anyLong(), isNull(), isNull(), isNull(), + when(solutionService.getSolutionList(any(User.class), anyLong(), any(GetSolutionListRequest.class), any(Pageable.class))) .thenThrow(new ProblemValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 문제 입니다.")); // when, then mockMvc.perform(get("/api/problems/{problemId}/solutions", problemId) - .header("Authorization", token)) + .header("Authorization", token) + .param("page", "0") + .param("size", "20")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("존재하지 않는 문제 입니다.")); - verify(solutionService, times(1)).getSolutionList(user, problemId, null, null, null, pageable); + verify(solutionService, times(1)).getSolutionList( + any(User.class), + eq(problemId), + any(GetSolutionListRequest.class), + any(Pageable.class) + ); } @Test @DisplayName("풀이 목록 조회 실패 : 존재하지 않는 그룹") void getSolutionListFailed_2() throws Exception { // given - Pageable pageable = PageRequest.of(0, 20); - when(solutionService.getSolutionList(any(User.class), anyLong(), isNull(), isNull(), isNull(), + when(solutionService.getSolutionList(any(User.class), anyLong(), any(GetSolutionListRequest.class), any(Pageable.class))) .thenThrow(new StudyGroupValidationException(HttpStatus.NOT_FOUND.value(), "존재하지 않는 그룹 입니다.")); // when, then mockMvc.perform(get("/api/problems/{problemId}/solutions", problemId) - .header("Authorization", token)) + .header("Authorization", token) + .param("page", "0") + .param("size", "20")) .andExpect(status().isNotFound()) .andExpect(jsonPath("$.error").value("존재하지 않는 그룹 입니다.")); - verify(solutionService, times(1)).getSolutionList(user, problemId, null, null, null, pageable); + verify(solutionService, times(1)).getSolutionList( + any(User.class), + eq(problemId), + any(GetSolutionListRequest.class), + any(Pageable.class) + ); } @Test @@ -134,15 +152,23 @@ void getSolutionListFailed_2() throws Exception { void getSolutionListFailed_3() throws Exception { // given Pageable pageable = PageRequest.of(0, 20); - when(solutionService.getSolutionList(any(User.class), anyLong(), isNull(), isNull(), isNull(), + GetSolutionListRequest request = GetSolutionListRequest.builder().language(null).language(null).nickname(null).build(); + when(solutionService.getSolutionList(any(User.class), anyLong(),any(GetSolutionListRequest.class), any(Pageable.class))) .thenThrow(new GroupMemberValidationException(HttpStatus.FORBIDDEN.value(), "참여하지 않은 그룹 입니다.")); // when, then mockMvc.perform(get("/api/problems/{problemId}/solutions", problemId) - .header("Authorization", token)) + .header("Authorization", token) + .param("page", "0") + .param("size", "20")) .andExpect(status().isForbidden()) .andExpect(jsonPath("$.error").value("참여하지 않은 그룹 입니다.")); - verify(solutionService, times(1)).getSolutionList(user, problemId, null, null, null, pageable); + verify(solutionService, times(1)).getSolutionList( + any(User.class), + eq(problemId), + any(GetSolutionListRequest.class), + any(Pageable.class) + ); } @Test diff --git a/src/test/java/com/gamzabat/algohub/feature/studygroup/controller/StudyGroupControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/studygroup/controller/StudyGroupControllerTest.java index 6ee99d6e..634b0f85 100644 --- a/src/test/java/com/gamzabat/algohub/feature/studygroup/controller/StudyGroupControllerTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/studygroup/controller/StudyGroupControllerTest.java @@ -2,9 +2,13 @@ import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.doNothing; +import static org.mockito.BDDMockito.doThrow; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; +import static org.mockito.BDDMockito.when; import static org.mockito.Mockito.anyLong; import static org.mockito.Mockito.anyString; -import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -55,6 +59,7 @@ import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.group.studygroup.service.JoinRequestService; import com.gamzabat.algohub.feature.group.studygroup.service.StudyGroupService; import com.gamzabat.algohub.feature.image.service.ImageService; import com.gamzabat.algohub.feature.problem.repository.ProblemRepository; @@ -79,6 +84,8 @@ class StudyGroupControllerTest { @MockBean private StudyGroupService studyGroupService; @MockBean + private JoinRequestService joinRequestService; + @MockBean private StudyGroupRepository studyGroupRepository; @MockBean private GroupMemberRepository groupMemberRepository; @@ -865,4 +872,5 @@ void editGroupVisibilityFailed_2() throws Exception { .andExpect(jsonPath("$.error").value("참여하지 않은 그룹입니다.")); verify(studyGroupService, times(1)).editStudyGroupVisibility(user, groupId, request); } + } \ No newline at end of file diff --git a/src/test/java/com/gamzabat/algohub/feature/user/controller/UserControllerTest.java b/src/test/java/com/gamzabat/algohub/feature/user/controller/UserControllerTest.java index d42cb9d9..0563c54f 100644 --- a/src/test/java/com/gamzabat/algohub/feature/user/controller/UserControllerTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/user/controller/UserControllerTest.java @@ -92,7 +92,7 @@ void setUp() { @DisplayName("회원 가입 성공") void register() throws Exception { // given - RegisterRequest request = new RegisterRequest("password", "nickname"); + RegisterRequest request = new RegisterRequest("password", "nickname", "bjNickname"); String requestJson = objectMapper.writeValueAsString(request); MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", requestJson.getBytes()); @@ -115,7 +115,7 @@ void register() throws Exception { @DisplayName("회원 가입 성공 : 프로필 사진 X") void register_2() throws Exception { // given - RegisterRequest request = new RegisterRequest("password", "nickname"); + RegisterRequest request = new RegisterRequest("password", "nickname", "bjNickname"); String requestJson = objectMapper.writeValueAsString(request); MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", requestJson.getBytes()); @@ -133,13 +133,15 @@ void register_2() throws Exception { @ParameterizedTest @CsvSource(value = { - " '', nickname, password : 비밀번호는 필수 입력입니다.", - "password, '', nickname : 닉네임은 필수 입력입니다." + " '', nickname, bjNickname, password : 비밀번호는 필수 입력입니다.", + "password, '', bjNickname, nickname : 닉네임은 필수 입력입니다.", + "password, nickname, '', bjNickname : 백준 아이디는 필수 입력입니다.", }, nullValues = "null") @DisplayName("회원 가입 실패 : 잘못된 요청") - void registerFailed_1(String password, String nickname, String exceptionMessage) throws Exception { + void registerFailed_1(String password, String nickname, String bjNickname, String exceptionMessage) throws + Exception { // given - RegisterRequest request = new RegisterRequest(password, nickname); + RegisterRequest request = new RegisterRequest(password, nickname, bjNickname); String requestJson = objectMapper.writeValueAsString(request); MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", requestJson.getBytes()); @@ -161,7 +163,7 @@ void registerFailed_1(String password, String nickname, String exceptionMessage) @DisplayName("회원가입 실패 : 이미 가입 된 이메일") void registerFailed_2() throws Exception { // given - RegisterRequest request = new RegisterRequest("password", "nickname"); + RegisterRequest request = new RegisterRequest("password", "nickname", "bjNickname"); String requestJson = objectMapper.writeValueAsString(request); MockMultipartFile requestPart = new MockMultipartFile("request", "", "application/json", requestJson.getBytes()); diff --git a/src/test/java/com/gamzabat/algohub/feature/user/service/UserServiceTest.java b/src/test/java/com/gamzabat/algohub/feature/user/service/UserServiceTest.java index a34b7a79..ed1bcd64 100644 --- a/src/test/java/com/gamzabat/algohub/feature/user/service/UserServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/feature/user/service/UserServiceTest.java @@ -139,7 +139,7 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { void register() { // given String prefix = "1_test@email.com"; - RegisterRequest request = new RegisterRequest(password, nickname); + RegisterRequest request = new RegisterRequest(password, nickname, bjNickname); MockMultipartFile profileImage = new MockMultipartFile("image", "image.jpg", "image/jpeg", "test".getBytes()); when(redisService.getValues(EMAIL_VERIFICATION_TOKEN)).thenReturn(email); when(userRepository.save(any(User.class))).thenReturn(user); diff --git a/src/test/java/com/gamzabat/algohub/service/EdgeCaseServiceTest.java b/src/test/java/com/gamzabat/algohub/service/EdgeCaseServiceTest.java new file mode 100644 index 00000000..e0a3aae8 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/service/EdgeCaseServiceTest.java @@ -0,0 +1,443 @@ +package com.gamzabat.algohub.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +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.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gamzabat.algohub.enums.Role; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCase; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseLike; +import com.gamzabat.algohub.feature.edgecase.domain.EdgeCaseSortType; +import com.gamzabat.algohub.feature.edgecase.dto.CreateEdgeCaseRequest; +import com.gamzabat.algohub.feature.edgecase.dto.GetEdgeCaseListResponse; +import com.gamzabat.algohub.feature.edgecase.dto.GetEdgeCaseResponse; +import com.gamzabat.algohub.feature.edgecase.exception.CannotFoundEdgeCaseException; +import com.gamzabat.algohub.feature.edgecase.exception.NotAuthorizedUserException; +import com.gamzabat.algohub.feature.edgecase.repository.EdgeCaseLikeRepository; +import com.gamzabat.algohub.feature.edgecase.repository.EdgeCaseRepository; +import com.gamzabat.algohub.feature.edgecase.service.EdgeCaseService; +import com.gamzabat.algohub.feature.problem.service.ProblemService; +import com.gamzabat.algohub.feature.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class EdgeCaseServiceTest { + @InjectMocks + private EdgeCaseService edgeCaseService; + + @Captor + private ArgumentCaptor edgeCaseCaptor; + + @Captor + private ArgumentCaptor edgeCaseLikeCaptor; + + @Mock + private EdgeCaseRepository edgeCaseRepository; + + @Mock + private EdgeCaseLikeRepository edgeCaseLikeRepository; + + @Mock + private ProblemService problemService; + + private User user, user2; + private EdgeCase edgeCase1, edgeCase2, edgeCase3; + private EdgeCaseLike edgeCaseLike1, edgeCaseLike2, edgeCaseLike3; + + @BeforeEach + void setUp() throws NoSuchFieldException, IllegalAccessException { + user = User.builder().email("email1").password("password").nickname("nickname1") + .role(Role.USER).profileImage("image").build(); + user2 = User.builder().email("email2").password("password").nickname("nickname2") + .role(Role.USER).profileImage("image").build(); + edgeCaseLike1 = EdgeCaseLike.builder().user(user).edgeCase(edgeCase1).build(); + edgeCaseLike2 = EdgeCaseLike.builder().user(user2).edgeCase(edgeCase2).build(); + edgeCaseLike3 = EdgeCaseLike.builder().user(user2).edgeCase(edgeCase1).build(); + + edgeCase1 = EdgeCase.builder() + .level(3) + .link("https://www.acmicpc.net/problem/1001") + .problemNumber(1001) + .title("A-B") + .input("0 0") + .output("0") + .author(user) + .build(); + + edgeCase2 = EdgeCase.builder() + .level(3) + .link("https://www.acmicpc.net/problem/1001") + .problemNumber(1001) + .title("A-B") + .input("5 5") + .output("0") + .author(user) + .build(); + + edgeCase3 = EdgeCase.builder() + .level(3) + .link("https://www.acmicpc.net/problem/1002") + .problemNumber(1002) + .title("Turret") + .input("0 0 13 40 0 37") + .output("2") + .author(user2) + .build(); + + Field userId = User.class.getDeclaredField("id"); + userId.setAccessible(true); + userId.set(user, 1L); + userId.set(user2, 2L); + + Field edgeCaseId = EdgeCase.class.getDeclaredField("id"); + edgeCaseId.setAccessible(true); + edgeCaseId.set(edgeCase1, 1L); + edgeCaseId.set(edgeCase2, 2L); + edgeCaseId.set(edgeCase3, 3L); + + Field edgeCaseLikeCount = EdgeCase.class.getDeclaredField("likeCount"); + edgeCaseLikeCount.setAccessible(true); + edgeCaseLikeCount.set(edgeCase1, 10); + edgeCaseLikeCount.set(edgeCase2, 5); + edgeCaseLikeCount.set(edgeCase3, 20); + + Field edgeCaseCreatedAt = EdgeCase.class.getDeclaredField("createdAt"); + edgeCaseCreatedAt.setAccessible(true); + edgeCaseCreatedAt.set(edgeCase1, LocalDateTime.now().minusDays(3)); + edgeCaseCreatedAt.set(edgeCase2, LocalDateTime.now().minusDays(2)); + edgeCaseCreatedAt.set(edgeCase3, LocalDateTime.now().minusDays(1)); + } + + @Test + @DisplayName("반례 생성 성공") + void createEdgeCaseSuccess() { + //given + CreateEdgeCaseRequest request = CreateEdgeCaseRequest.builder() + .link("https://www.acmicpc.net/problem/1000") + .input("1 2") + .output("3") + .build(); + ObjectMapper mapper = new ObjectMapper(); + JsonNode mockJsonNode = mapper.createObjectNode() + .put("title", "A+B") + .put("problemId", 1234) + .put("level", 1); + given(problemService.fetchProblemDetails(anyString())).willReturn(mockJsonNode); + given(problemService.getProblemLevel(mockJsonNode)).willReturn(1); + given(problemService.getProblemTitle(mockJsonNode)).willReturn("A+B"); + //when + edgeCaseService.createEdgeCase(user, request); + + //then + verify(edgeCaseRepository, times(1)).save(edgeCaseCaptor.capture()); + + EdgeCase result = edgeCaseCaptor.getValue(); + + Assertions.assertAll( + () -> assertThat(result.getLink()).isEqualTo("https://www.acmicpc.net/problem/1000"), + () -> assertThat(result.getProblemNumber()).isEqualTo(1000), + () -> assertThat(result.getTitle()).isEqualTo("A+B"), + () -> assertThat(result.getLevel()).isEqualTo(1), + () -> assertThat(result.getInput()).isEqualTo("1 2"), + () -> assertThat(result.getOutput()).isEqualTo("3"), + () -> assertThat(result.getLikeCount()).isEqualTo(0), + () -> assertThat(result.getAuthor()).isEqualTo(user), + () -> assertThat(result.getCreatedAt()).isNotNull() + ); + } + + @Test + @DisplayName("반례 리스트 조회 성공 // 여러개 반환") + void getEdgeCaseListSuccess_1() { + //given + Integer problemNumber = 1001; + List edgeCaseList = Arrays.asList(edgeCase1, edgeCase2); + when(edgeCaseRepository.findAllByProblemNumberOrderByCreatedAtDesc(problemNumber)) + .thenReturn(edgeCaseList); + + //when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null,problemNumber, EdgeCaseSortType.RECENT); + + //then + assertEquals(2, response.edgeCaseList().size()); + + GetEdgeCaseResponse firstResponse = response.edgeCaseList().get(0); + assertEquals(3, firstResponse.level()); + assertEquals(1001, firstResponse.problemNumber()); + assertEquals("A-B", firstResponse.title()); + assertEquals("0 0", firstResponse.input()); + assertEquals("0", firstResponse.output()); + + GetEdgeCaseResponse secondResponse = response.edgeCaseList().get(1); + assertEquals(3, secondResponse.level()); + assertEquals(1001, secondResponse.problemNumber()); + assertEquals("A-B", secondResponse.title()); + assertEquals("5 5", secondResponse.input()); + assertEquals("0", secondResponse.output()); + } + + @Test + @DisplayName("반례 리스트 조회 성공 // 모든 리스트 반환") + void getEdgeCaseListSuccess_2() { + // given + Integer problemNumber = null; + List edgeCaseList = Arrays.asList(edgeCase1, edgeCase2, edgeCase3); + when(edgeCaseRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(edgeCaseList); + + // when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null, problemNumber, EdgeCaseSortType.RECENT); + + // then + assertEquals(3, response.edgeCaseList().size()); + + GetEdgeCaseResponse firstResponse = response.edgeCaseList().get(0); + assertEquals(3, firstResponse.level()); + assertEquals(1001, firstResponse.problemNumber()); + assertEquals("A-B", firstResponse.title()); + assertEquals("0 0", firstResponse.input()); + assertEquals("0", firstResponse.output()); + + GetEdgeCaseResponse secondResponse = response.edgeCaseList().get(1); + assertEquals(3, secondResponse.level()); + assertEquals(1001, secondResponse.problemNumber()); + assertEquals("A-B", secondResponse.title()); + assertEquals("5 5", secondResponse.input()); + assertEquals("0", secondResponse.output()); + + GetEdgeCaseResponse thirdResponse = response.edgeCaseList().get(2); + assertEquals(3, thirdResponse.level()); + assertEquals(1002, thirdResponse.problemNumber()); + assertEquals("Turret", thirdResponse.title()); + assertEquals("0 0 13 40 0 37", thirdResponse.input()); + assertEquals("2", thirdResponse.output()); + } + + @Test + @DisplayName("반례 리스트 조회 성공 (LIKE) // 문제 번호O") + void getEdgeCaseListSuccess_Like_WithProblemNumber() { + //given + Integer problemNumber = 1001; + List edgeCaseList = Arrays.asList(edgeCase1, edgeCase2); + when(edgeCaseRepository.findAllByProblemNumberOrderByLikeCountDesc(problemNumber)) + .thenReturn(edgeCaseList); + + //when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null, problemNumber, EdgeCaseSortType.LIKE); + + //then + assertThat(response.edgeCaseList()).hasSize(2); + + // 정렬 순서 검증 (ID 1 -> ID 2) + Assertions.assertAll( + () -> assertThat(response.edgeCaseList().get(0).edgeCaseId()).isEqualTo(edgeCase1.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(1).edgeCaseId()).isEqualTo(edgeCase2.getId().intValue()) + ); + } + + @Test + @DisplayName("반례 리스트 조회 성공 (LIKE) // 모든 리스트") + void getEdgeCaseListSuccess_Like_NoProblemNumber() { + // given + Integer problemNumber = null; + // setUp 순서 (전체): 3(20개) -> 1(10개) -> 2(5개) + List edgeCaseList = Arrays.asList(edgeCase3, edgeCase1, edgeCase2); + when(edgeCaseRepository.findAllByOrderByLikeCountDesc()) + .thenReturn(edgeCaseList); + + // when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null,problemNumber, EdgeCaseSortType.LIKE); + + // then + assertThat(response.edgeCaseList()).hasSize(3); + + // 정렬 순서 검증 (ID 3 -> ID 1 -> ID 2) + Assertions.assertAll( + () -> assertThat(response.edgeCaseList().get(0).edgeCaseId()).isEqualTo(edgeCase3.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(1).edgeCaseId()).isEqualTo(edgeCase1.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(2).edgeCaseId()).isEqualTo(edgeCase2.getId().intValue()) + ); + } + + @Test + @DisplayName("반례 리스트 조회 성공 (OLD) // 문제 번호O") + void getEdgeCaseListSuccess_Old_WithProblemNumber() { + //given + Integer problemNumber = 1001; + // setUp 순서 (문제 1001번): 1(오래됨) -> 2(최신) + List edgeCaseList = Arrays.asList(edgeCase1, edgeCase2); + when(edgeCaseRepository.findAllByProblemNumberOrderByCreatedAtAsc(problemNumber)) + .thenReturn(edgeCaseList); + + //when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null, problemNumber, EdgeCaseSortType.OLD); + + //then + assertThat(response.edgeCaseList()).hasSize(2); + + // 정렬 순서 검증 (ID 1 -> ID 2) + Assertions.assertAll( + () -> assertThat(response.edgeCaseList().get(0).edgeCaseId()).isEqualTo(edgeCase1.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(1).edgeCaseId()).isEqualTo(edgeCase2.getId().intValue()) + ); + } + + @Test + @DisplayName("반례 리스트 조회 성공 (OLD) // 모든 리스트") + void getEdgeCaseListSuccess_Old_NoProblemNumber() { + // given + Integer problemNumber = null; + // setUp 순서 (전체): 1(오래됨) -> 2 -> 3(최신) + List edgeCaseList = Arrays.asList(edgeCase1, edgeCase2, edgeCase3); + when(edgeCaseRepository.findAllByOrderByCreatedAtAsc()) + .thenReturn(edgeCaseList); + + // when + GetEdgeCaseListResponse response = edgeCaseService.getEdgeCaseList(null, problemNumber, EdgeCaseSortType.OLD); + + // then + assertThat(response.edgeCaseList()).hasSize(3); + + // 정렬 순서 검증 (ID 1 -> ID 2 -> ID 3) + Assertions.assertAll( + () -> assertThat(response.edgeCaseList().get(0).edgeCaseId()).isEqualTo(edgeCase1.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(1).edgeCaseId()).isEqualTo(edgeCase2.getId().intValue()), + () -> assertThat(response.edgeCaseList().get(2).edgeCaseId()).isEqualTo(edgeCase3.getId().intValue()) + ); + } + + @Test + @DisplayName("반례 리스트 조회 성공 // user가 있고 edgeCaseList가 비어있지 않은 경우") + void getEdgeCaseListSuccess_whenUserExistsAndListNotEmpty() { + // given + List edgeCaseList = List.of(edgeCase1, edgeCase2); + when(edgeCaseRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(edgeCaseList); + when(edgeCaseLikeRepository.findByUserAndEdgeCaseIn(user, edgeCaseList)) + .thenReturn(Collections.emptyList()); + + // when + edgeCaseService.getEdgeCaseList(user, null, EdgeCaseSortType.RECENT); + + // then + verify(edgeCaseLikeRepository, times(1)) + .findByUserAndEdgeCaseIn(user, edgeCaseList); + } + + @Test + @DisplayName("반례 리스트 조회 성공 // user가 있고 edgeCaseList가 비어있는 경우") + void getEdgeCaseListSuccess_whenUserExistsAndListEmpty() { + // given + when(edgeCaseRepository.findAllByOrderByCreatedAtDesc()) + .thenReturn(Collections.emptyList()); + + // when + edgeCaseService.getEdgeCaseList(user, null, EdgeCaseSortType.RECENT); + + // then + verify(edgeCaseLikeRepository, never()) + .findByUserAndEdgeCaseIn(any(), any()); + } + + @Test + @DisplayName("반례 삭제 성공") + void deleteEdgeCase_success() { + //given + when(edgeCaseRepository.findById(1L)).thenReturn(Optional.of(edgeCase1)); + + //when + edgeCaseService.deleteEdgeCase(user, 1L); + + //then + verify(edgeCaseRepository, times(1)).delete(edgeCase1); + verify(edgeCaseRepository, times(1)).findById(1L); + } + + @Test + @DisplayName("반례 삭제 실패 // 존재하지 않는 반례") + void deleteEdgeCase_failed_1() { + //given + when(edgeCaseRepository.findById(100L)).thenReturn(Optional.empty()); + + //when, then + assertThatThrownBy(() -> edgeCaseService.deleteEdgeCase(user, 100L)) + .isInstanceOf(CannotFoundEdgeCaseException.class) + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.NOT_FOUND) + .hasFieldOrPropertyWithValue("errors", "존재하지 않는 반례입니다."); + } + + @Test + @DisplayName("반례 삭제 실패 // 권한 없음") + void deleteEdgeCase_failed_2() { + //given + when(edgeCaseRepository.findById(3L)).thenReturn(Optional.of(edgeCase3)); + + //when, then + assertThatThrownBy(() -> edgeCaseService.deleteEdgeCase(user, 3L)) + .isInstanceOf(NotAuthorizedUserException.class) + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.FORBIDDEN) + .hasFieldOrPropertyWithValue("error", "반례를 삭제할 권한이 없습니다."); + } + + @Test + @DisplayName("반례 좋아요 토글 성공 (기본상태 true)") + void addEdgeCase_success_1() { + //given + when(edgeCaseRepository.findById(2L)).thenReturn(Optional.of(edgeCase2)); + when(edgeCaseLikeRepository.findByEdgeCaseAndUser(edgeCase2, user2)).thenReturn(Optional.of(edgeCaseLike2)); + + //when + edgeCaseService.togleEdgeCaseLike(user2, 2L); + + //then + verify(edgeCaseLikeRepository, times(1)).delete(any(EdgeCaseLike.class)); + } + + @Test + @DisplayName("반례 좋아요 토글 성공 (기본상태 false)") + void addEdgeCase_success_2() { + //given + when(edgeCaseRepository.findById(2L)).thenReturn(Optional.of(edgeCase2)); + when(edgeCaseLikeRepository.findByEdgeCaseAndUser(edgeCase2, user2)).thenReturn(Optional.empty()); + + //when + edgeCaseService.togleEdgeCaseLike(user2, 2L); + + //then + verify(edgeCaseLikeRepository, times(1)).save(any(EdgeCaseLike.class)); + } + + @Test + @DisplayName("반례 좋아요 추가 실패 // 존재하지 않는 반례") + void addEdgeCase_failed_1() { + //given + when(edgeCaseRepository.findById(100L)).thenReturn(Optional.empty()); + + //when, then + assertThatThrownBy(() -> edgeCaseService.togleEdgeCaseLike(user, 100L)) + .isInstanceOf(CannotFoundEdgeCaseException.class) + .hasFieldOrPropertyWithValue("httpStatus", HttpStatus.NOT_FOUND) + .hasFieldOrPropertyWithValue("errors", "존재하지 않는 반례입니다."); + } +} + diff --git a/src/test/java/com/gamzabat/algohub/service/JoinRequestServiceTest.java b/src/test/java/com/gamzabat/algohub/service/JoinRequestServiceTest.java new file mode 100644 index 00000000..d59eb206 --- /dev/null +++ b/src/test/java/com/gamzabat/algohub/service/JoinRequestServiceTest.java @@ -0,0 +1,278 @@ +package com.gamzabat.algohub.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +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.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.gamzabat.algohub.enums.JoinRequestStatus; +import com.gamzabat.algohub.enums.Role; +import com.gamzabat.algohub.feature.group.ranking.repository.RankingRepository; +import com.gamzabat.algohub.feature.group.studygroup.domain.GroupMember; +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; +import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; +import com.gamzabat.algohub.feature.group.studygroup.dto.UpdateJoinRequestStatusRequest; +import com.gamzabat.algohub.feature.group.studygroup.etc.RoleOfGroupMember; +import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; +import com.gamzabat.algohub.feature.group.studygroup.exception.JoinRequestException; +import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.JoinRequestRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; +import com.gamzabat.algohub.feature.group.studygroup.service.JoinRequestService; +import com.gamzabat.algohub.feature.group.studygroup.service.StudyGroupService; +import com.gamzabat.algohub.feature.notification.repository.NotificationSettingRepository; +import com.gamzabat.algohub.feature.problem.domain.Problem; +import com.gamzabat.algohub.feature.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class JoinRequestServiceTest { + @InjectMocks + private JoinRequestService joinRequestService; + @Mock + private StudyGroupService studyGroupService; + @Mock + private StudyGroupRepository studyGroupRepository; + @Mock + private JoinRequestRepository joinRequestRepository; + @Mock + private GroupMemberRepository groupMemberRepository; + @Mock + private NotificationSettingRepository notificationSettingRepository; + @Mock + private RankingRepository rankingRepository; + + private User user, owner, user2, user3, requester; + private StudyGroup group; + private Problem problem1, problem2; + private GroupMember groupMember1, groupMember2, groupMember3; + private GroupMember ownerGroupmember; + private JoinRequest joinRequest; + @Captor + private ArgumentCaptor groupCaptor; + @Captor + private ArgumentCaptor memberCaptor; + + @BeforeEach + void setUp() throws NoSuchFieldException, IllegalAccessException { + user = User.builder().email("email1").password("password").nickname("nickname1") + .role(Role.USER).profileImage("image1").build(); + owner = User.builder().email("email1").password("password").nickname("nickname1") + .role(Role.USER).profileImage("image1").build(); + user2 = User.builder().email("email2").password("password").nickname("nickname2") + .role(Role.USER).profileImage("image2").build(); + user3 = User.builder().email("email3").password("password").nickname("nickname3") + .role(Role.USER).profileImage("image3").build(); + requester = User.builder().email("eamilRequester").password("password").nickname("requester") + .role(Role.USER).profileImage("imageForRequester").build(); + + group = StudyGroup.builder() + .name("name") + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(1)) + .groupImage("imageUrl") + .groupCode("code") + .build(); + ownerGroupmember = GroupMember.builder() + .studyGroup(group) + .user(owner) + .role(RoleOfGroupMember.OWNER) + .joinDate(LocalDate.now()) + .build(); + groupMember1 = GroupMember.builder() + .studyGroup(group) + .user(user) + .role(RoleOfGroupMember.OWNER) + .joinDate(LocalDate.now()) + .build(); + groupMember2 = GroupMember.builder() + .studyGroup(group) + .user(user2) + .role(RoleOfGroupMember.PARTICIPANT) + .joinDate(LocalDate.now()) + .build(); + groupMember3 = GroupMember.builder() + .studyGroup(group) + .user(user3) + .role(RoleOfGroupMember.ADMIN) + .joinDate(LocalDate.now()) + .build(); + + problem1 = Problem.builder() + .studyGroup(group) + .build(); + problem2 = Problem.builder() + .studyGroup(group) + .build(); + + Field userField = User.class.getDeclaredField("id"); + userField.setAccessible(true); + userField.set(user, 1L); + userField.set(owner, 1L); + userField.set(user2, 2L); + userField.set(user3, 3L); + userField.set(requester, 4L); + + Field groupId = StudyGroup.class.getDeclaredField("id"); + groupId.setAccessible(true); + groupId.set(group, 10L); + + Field memberId = GroupMember.class.getDeclaredField("id"); + memberId.setAccessible(true); + memberId.set(groupMember1, 100L); + memberId.set(groupMember2, 200L); + memberId.set(groupMember3, 300L); + //For Join Request Service Test + joinRequest = new JoinRequest(group, requester); + Field requestId = JoinRequest.class.getDeclaredField("id"); + Field requestGroup = JoinRequest.class.getDeclaredField("group"); + requestGroup.setAccessible(true); + requestGroup.set(joinRequest, group); + requestId.setAccessible(true); + requestId.set(joinRequest, 1000L); + + } + + @Test + @DisplayName("그룹 가입 요청 성공") + void joinRequest_Success() { + // given + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.existsByUserAndStudyGroup(requester, group)).thenReturn(false); + when(joinRequestRepository.existsByGroup_IdAndRequester_Id(group.getId(), requester.getId())) + .thenReturn(false); + + // when + joinRequestService.joinRequest(requester, 10L); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(JoinRequest.class); + verify(joinRequestRepository, times(1)).save(captor.capture()); + JoinRequest savedRequest = captor.getValue(); + + assertThat(savedRequest.getRequester()).isEqualTo(requester); + assertThat(savedRequest.getGroup()).isEqualTo(group); + } + + @Test + @DisplayName("그룹 가입 요청 실패 : 이미 가입한 그룹") + void joinRequest_Fail_AlreadyMember() { + // given + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.existsByUserAndStudyGroup(requester, group)).thenReturn(true); + + // when, then + assertThatThrownBy(() -> joinRequestService.joinRequest(requester, 10L)) + .isInstanceOf(GroupMemberValidationException.class) + .hasFieldOrPropertyWithValue("error", "이미 가입한 그룹입니다"); + } + + @Test + @DisplayName("그룹 가입 요청 실패 : 이미 요청한 그룹") + void joinRequest_Fail_AlreadyRequested() { + // given + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.existsByUserAndStudyGroup(requester, group)).thenReturn(false); + when(joinRequestRepository.existsByGroup_IdAndRequester_Id(group.getId(), requester.getId())).thenReturn( + true); + + // when, then + assertThatThrownBy(() -> joinRequestService.joinRequest(requester, 10L)) + .isInstanceOf(JoinRequestException.class) + .hasMessage("이미 요청한 그룹입니다."); + } + + @Test + @DisplayName("가입 요청 목록 조회 성공") + void getAllJoinRequests_Success() { + // given + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findByUserAndStudyGroup(owner, group)).thenReturn(Optional.of(ownerGroupmember)); + when(joinRequestRepository.findAllByGroupIdWithFetch(10L)).thenReturn(List.of(joinRequest)); + + // when + List requests = joinRequestService.getAllJoinRequests(owner, 10L); + + // then + assertThat(requests).hasSize(1); + assertThat(requests.get(0).getRequester().getNickname()).isEqualTo("requester"); + } + + @Test + @DisplayName("가입 요청 목록 조회 실패 : 권한 없음") + void getAllJoinRequests_Fail_NoPermission() { + // given + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findByUserAndStudyGroup(groupMember2.getUser(), group)).thenReturn( + Optional.of(groupMember2)); + + // when, then + assertThatThrownBy(() -> joinRequestService.getAllJoinRequests(groupMember2.getUser(), 10L)) + .isInstanceOf(JoinRequestException.class) + .hasMessage("요청 목록을 조회할 권한이 없습니다."); + } + + @Test + @DisplayName("가입 요청 승인 성공") + void approveJoinRequest_Success() { + // given + when(joinRequestRepository.findById(1000L)).thenReturn(Optional.of(joinRequest)); + when(studyGroupRepository.findById(joinRequest.getGroup().getId())).thenReturn(Optional.of(group)); + when(groupMemberRepository.findByUserAndStudyGroup(owner, group)).thenReturn(Optional.of(ownerGroupmember)); + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.APPROVE); + // when + joinRequestService.updateJoinRequest(owner, 1000L, request); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(GroupMember.class); + verify(groupMemberRepository, times(1)).save(captor.capture()); + GroupMember newMember = captor.getValue(); + + assertThat(newMember.getUser()).isEqualTo(requester); + assertThat(newMember.getRole()).isEqualTo(RoleOfGroupMember.PARTICIPANT); + verify(joinRequestRepository, times(1)).delete(joinRequest); + + } + + @Test + @DisplayName("가입 요청 승인 실패 : 요청 없음") + void approveJoinRequest_Fail_RequestNotFound() { + // given + when(joinRequestRepository.findById(100L)).thenReturn(Optional.empty()); + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.APPROVE); + + // when, then + assertThatThrownBy(() -> joinRequestService.updateJoinRequest(owner, 100L, request)) + .isInstanceOf(JoinRequestException.class) + .hasMessage("해당 요청이 존재하지 않습니다"); + } + + @Test + @DisplayName("가입 요청 거절 성공 STATUS : PENDING -> REJECT 로 변경") + void rejectJoinRequest_Success() { + // given + when(joinRequestRepository.findById(1000L)).thenReturn(Optional.of(joinRequest)); + when(studyGroupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findByUserAndStudyGroup(owner, group)).thenReturn(Optional.of(ownerGroupmember)); + UpdateJoinRequestStatusRequest request = new UpdateJoinRequestStatusRequest(JoinRequestStatus.REJECT); + + // when + joinRequestService.updateJoinRequest(owner, 1000L, request); + + // then + verify(joinRequestRepository, times(1)).delete(joinRequest); + } + +} diff --git a/src/test/java/com/gamzabat/algohub/service/ProblemServiceTest.java b/src/test/java/com/gamzabat/algohub/service/ProblemServiceTest.java index b481ce3c..dd500a1b 100644 --- a/src/test/java/com/gamzabat/algohub/service/ProblemServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/service/ProblemServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; import com.gamzabat.algohub.common.DateFormatUtil; @@ -175,7 +176,8 @@ void createProblem_SuccessByADMIN() { assertThat(result.getLevel()).isEqualTo(1); assertThat(result.getStartDate()).isEqualTo(LocalDate.now()); assertThat(result.getEndDate()).isEqualTo(LocalDate.now().plusDays(10)); - verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(), + any()); } @Test @@ -272,6 +274,29 @@ void createProblemFailed_6() { .hasFieldOrPropertyWithValue("error", "solved.ac API로부터 예상치 못한 응답을 받았습니다."); } + @Test + @DisplayName("문제 생성 실패 : solved.ac API 타임아웃 발생") + void createProblemFailed_solvedAcApiTimeout() { + // given + CreateProblemRequest request = CreateProblemRequest.builder() + .link("https://www.acmicpc.net/problem/00") + .startDate(LocalDate.now().minusDays(7)) + .endDate(LocalDate.now()) + .build(); + + when(groupRepository.findById(10L)).thenReturn(Optional.of(group)); + when(groupMemberRepository.findByUserAndStudyGroup(user, group)).thenReturn(Optional.of(groupMember1)); + + when(restTemplate.getForEntity(anyString(), eq(String.class))) + .thenThrow(new ResourceAccessException("I/O error: Read timed out")); + + // when, then + assertThatThrownBy(() -> problemService.createProblem(user, 10L, request)) + .isInstanceOf(SolvedAcApiErrorException.class) + .hasFieldOrPropertyWithValue("code", HttpStatus.GATEWAY_TIMEOUT.value()) + .hasFieldOrPropertyWithValue("error", "solved.ac API 응답이 지연되거나 연결에 실패했습니다. 잠시 후 다시 시도해주세요."); + } + @Test @DisplayName("문제 정보 수정 성공") void editProblem() { @@ -358,30 +383,6 @@ void editProblemFailed_4() { .hasFieldOrPropertyWithValue("error", "문제 수정 권한이 없습니다. 방장, 부방장일 경우에만 수정이 가능합니다."); } - @Test - @DisplayName("문제 정보 수정 실패 : 문제 시작 날짜를 오늘 이전의 날짜로 요청한 경우") - void editProblemFailed_6() { - // given - Problem problem = Problem.builder() - .studyGroup(group) - .link("link") - .startDate(LocalDate.now().plusDays(1)) - .endDate(LocalDate.now().plusDays(10)) - .build(); - EditProblemRequest request = EditProblemRequest.builder() - .startDate(LocalDate.now().minusDays(3)) - .endDate(LocalDate.now().plusDays(7)) - .build(); - when(problemRepository.findById(20L)).thenReturn(Optional.ofNullable(problem)); - when(groupRepository.findById(10L)).thenReturn(Optional.ofNullable(group)); - when(groupMemberRepository.findByUserAndStudyGroup(user, group)).thenReturn(Optional.ofNullable(groupMember1)); - // when, then - assertThatThrownBy(() -> problemService.editProblem(user, 20L, request)) - .isInstanceOf(ProblemValidationException.class) - .hasFieldOrPropertyWithValue("code", HttpStatus.BAD_REQUEST.value()) - .hasFieldOrPropertyWithValue("error", "문제 시작 날짜는 오늘 이전의 날짜로 수정할 수 없습니다."); - } - @Test @DisplayName("문제 정보 수정 실패 : 문제 마감 날짜를 오늘 이전의 날짜로 요청한 경우") void editProblemFailed_7() { @@ -800,7 +801,8 @@ void sendProblemNotification() { // when problemService.dailyProblemScheduler(); // then - verify(notificationService, times(20)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(20)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(), + any()); } @Test diff --git a/src/test/java/com/gamzabat/algohub/service/SolutionCommentServiceTest.java b/src/test/java/com/gamzabat/algohub/service/SolutionCommentServiceTest.java index 8cdd54a4..ae635d1a 100644 --- a/src/test/java/com/gamzabat/algohub/service/SolutionCommentServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/service/SolutionCommentServiceTest.java @@ -140,7 +140,7 @@ void createComment_1() { assertThat(result.getContent()).isEqualTo("content"); assertThat(result.getUser()).isEqualTo(user2); assertThat(result.getSolution()).isEqualTo(solution); - verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(),any()); } @Test @@ -164,7 +164,7 @@ void createComment_2() { assertThat(result.getContent()).isEqualTo("content"); assertThat(result.getUser()).isEqualTo(user); assertThat(result.getSolution()).isEqualTo(solution); - verify(notificationService, times(0)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(0)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(),any()); } @Test @@ -179,7 +179,7 @@ void createCommentFailed_1() { assertThatThrownBy(() -> commentService.createComment(user, 10L, request)) .isInstanceOf(SolutionValidationException.class) .hasFieldOrPropertyWithValue("error", "존재하지 않는 풀이 입니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -196,7 +196,7 @@ void createCommentFailed_2() { .isInstanceOf(ProblemValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.NOT_FOUND.value()) .hasFieldOrPropertyWithValue("error", "존재하지 않는 문제 입니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -214,7 +214,7 @@ void createCommentFailed_3() { .isInstanceOf(StudyGroupValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.NOT_FOUND.value()) .hasFieldOrPropertyWithValue("error", "존재하지 않는 그룹 입니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test @@ -233,7 +233,7 @@ void createCommentFailed_4() { .isInstanceOf(GroupMemberValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.FORBIDDEN.value()) .hasFieldOrPropertyWithValue("error", "참여하지 않은 그룹 입니다."); - verify(notificationService, never()).send(any(), any(), any(), any(), any(), any()); + verify(notificationService, never()).send(any(), any(), any(), any(), any(), any(), any()); } @Test diff --git a/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java b/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java index 9998146d..bc488242 100644 --- a/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/service/SolutionServiceTest.java @@ -43,8 +43,8 @@ import com.gamzabat.algohub.feature.solution.domain.SolutionComment; import com.gamzabat.algohub.feature.solution.dto.CreateSolutionRequest; import com.gamzabat.algohub.feature.solution.dto.GetCurrentSolvingStatusResponse; +import com.gamzabat.algohub.feature.solution.dto.GetSolutionListRequest; import com.gamzabat.algohub.feature.solution.dto.GetSolutionResponse; -import com.gamzabat.algohub.feature.solution.dto.GetSolutionWithGroupIdResponse; import com.gamzabat.algohub.feature.solution.enums.ProgressCategory; import com.gamzabat.algohub.feature.solution.exception.CannotFoundSolutionException; import com.gamzabat.algohub.feature.solution.repository.SolutionCommentRepository; @@ -145,7 +145,8 @@ void getSolutionList() { Page correctPage = new PageImpl<>(list.subList(10, 20), pageable, 10); List readComments = new ArrayList<>(commentList.subList(0, 25)); List unReadComments = new ArrayList<>(commentList.subList(25, 50)); - + GetSolutionListRequest requestCompileError = GetSolutionListRequest.builder().result("컴파일 에러").language(null).nickname(null).build(); + GetSolutionListRequest requestCorrect = GetSolutionListRequest.builder().result("맞았습니다!!").language(null).nickname(null).build(); when(studyGroupRepository.findById(30L)).thenReturn(Optional.ofNullable(group)); when(problemRepository.findById(10L)).thenReturn(Optional.ofNullable(problem)); when(groupMemberRepository.existsByUserAndStudyGroup(user, group)).thenReturn(true); @@ -162,9 +163,9 @@ void getSolutionList() { unReadComments.subList(i * 5, i * 5 + 5)); } // when - Page compileErrorResult = solutionService.getSolutionList(user, 10L, null, null, "컴파일 에러", + Page compileErrorResult = solutionService.getSolutionList(user, 10L, requestCompileError, pageable); - Page correctResult = solutionService.getSolutionList(user, 10L, null, null, "맞았습니다!!", + Page correctResult = solutionService.getSolutionList(user, 10L, requestCorrect, pageable); // then // 1) 컴파일 에러 풀이 목록 조회 @@ -227,7 +228,7 @@ void getSolutionList_2() { List list = new ArrayList<>(); List commentList = new ArrayList<>(); LocalDateTime fixedDateTime = LocalDateTime.now(); - + GetSolutionListRequest request = GetSolutionListRequest.builder().result(null).language("Java").nickname("nickname1").build(); setTestSolutionAndCommentList(list, commentList, fixedDateTime); Page solutionPage = new PageImpl<>(list.subList(10, 15), pageable, 5); @@ -239,8 +240,7 @@ void getSolutionList_2() { solutionPage); // when - Page result = solutionService.getSolutionList(user2, 10L, "nickname1", "Java", null, - pageable); + Page result = solutionService.getSolutionList(user2, 10L, request, pageable); // then assertThat(result.getContent().size()).isEqualTo(5); assertThat(result.getTotalElements()).isEqualTo(5); @@ -264,8 +264,9 @@ void getSolutionListFailed_1() { Pageable pageable = PageRequest.of(0, 20); when(problemRepository.findById(10L)).thenReturn(Optional.ofNullable(problem)); when(studyGroupRepository.findById(30L)).thenReturn(Optional.empty()); + GetSolutionListRequest request = GetSolutionListRequest.builder().result(null).language(null).nickname(null).build(); // when, then - assertThatThrownBy(() -> solutionService.getSolutionList(user, 10L, null, null, null, pageable)) + assertThatThrownBy(() -> solutionService.getSolutionList(user, 10L, request, pageable)) .isInstanceOf(StudyGroupValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.NOT_FOUND.value()) .hasFieldOrPropertyWithValue("error", "존재하지 않는 그룹 입니다."); @@ -277,8 +278,10 @@ void getSolutionListFailed_2() { // given Pageable pageable = PageRequest.of(0, 20); when(problemRepository.findById(10L)).thenReturn(Optional.empty()); + GetSolutionListRequest request = GetSolutionListRequest.builder().result(null).language(null).nickname(null).build(); + // when, then - assertThatThrownBy(() -> solutionService.getSolutionList(user, 10L, null, null, null, pageable)) + assertThatThrownBy(() -> solutionService.getSolutionList(user, 10L,request, pageable)) .isInstanceOf(ProblemValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.NOT_FOUND.value()) .hasFieldOrPropertyWithValue("error", "존재하지 않는 문제 입니다."); @@ -292,8 +295,10 @@ void getSolutionListFailed_3() { when(problemRepository.findById(10L)).thenReturn(Optional.ofNullable(problem)); when(studyGroupRepository.findById(30L)).thenReturn(Optional.ofNullable(group)); when(groupMemberRepository.existsByUserAndStudyGroup(user2, group)).thenReturn(false); + GetSolutionListRequest request = GetSolutionListRequest.builder().result(null).language(null).nickname(null).build(); + // when, then - assertThatThrownBy(() -> solutionService.getSolutionList(user2, 10L, null, null, null, pageable)) + assertThatThrownBy(() -> solutionService.getSolutionList(user2, 10L, request, pageable)) .isInstanceOf(GroupMemberValidationException.class) .hasFieldOrPropertyWithValue("code", HttpStatus.FORBIDDEN.value()) .hasFieldOrPropertyWithValue("error", "참여하지 않은 그룹 입니다."); @@ -551,11 +556,11 @@ void createSolution() throws NoSuchFieldException, IllegalAccessException { // then verify(solutionRepository, times(2)).save(any(Solution.class)); - verify(notificationService, times(2)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(2)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(),any()); } @Test - @DisplayName("그룹 내 진행 중인 나의 풀이 전체 조회 성공 : 문제 필터링") + @DisplayName("나의 풀이 전체 조회 성공 : 문제 필터링 // 그룹 내 진행 중인 ") void getMySolutionsInGroupInProgress() { // given Pageable pageable = PageRequest.of(0, 10); @@ -600,9 +605,9 @@ void getMySolutionsInGroupInProgress() { comments.subList(i * 10, i * 10 + 10)); } // when - Page responses = solutionService.getMySolutionsInGroupInProgress(user, groupId, + Page responses = solutionService.getMySolutionList(user, groupId, problemNumber, null, - null, pageable); + null, ProgressCategory.IN_PROGRESS, false, pageable); // then for (int i = 0; i < 5; i++) { assertThat(responses.getContent().get(i).getNickname()).isEqualTo("nickname1"); @@ -612,7 +617,7 @@ void getMySolutionsInGroupInProgress() { } @Test - @DisplayName("그룹 내 마감된 나의 풀이 전체 조회 성공 : 문제 필터링") + @DisplayName("나의 풀이 전체 조회 성공 : 문제 필터링 // 그룹 내 마감된 ") void getMySolutionsInGroupExpired() { // given Pageable pageable = PageRequest.of(0, 10); @@ -656,19 +661,20 @@ void getMySolutionsInGroupExpired() { comments.subList(i * 10, i * 10 + 10)); } // when - Page responses = solutionService.getMySolutionsInGroupExpired(user, groupId, + Page responses = solutionService.getMySolutionList(user, groupId, problemNumber, null, - null, pageable); + null,ProgressCategory.EXPIRED,false, pageable); // then for (int i = 0; i < 5; i++) { assertThat(responses.getContent().get(i).getNickname()).isEqualTo("nickname1"); assertThat(responses.getContent().get(i).getIsRead()).isEqualTo(false); assertThat(responses.getContent().get(i).getProblemLevel()).isEqualTo(problem1.getLevel()); + assertThat(responses.getContent().getFirst().getProblemId()).isEqualTo(problem1.getId()); } } @Test - @DisplayName("진행 중인 나의 풀이 전체 조회 성공") + @DisplayName("나의 풀이 전체 조회 성공 // 진행 중인 ") void getMySolutionsInProgress() { // given Pageable pageable = PageRequest.of(0, 10); @@ -704,20 +710,22 @@ void getMySolutionsInProgress() { comments.subList(i * 10, i * 10 + 10)); } // when - Page responses = solutionService.getMySolutionsInProgress(user, null, null, - null, pageable); + Page responses = solutionService.getMySolutionList(user, null, null, + null,null,ProgressCategory.IN_PROGRESS, false, pageable); // then for (int i = 0; i < 5; i++) { assertThat(responses.getContent().get(i).getNickname()).isEqualTo("nickname1"); assertThat(responses.getContent().get(i).getIsRead()).isEqualTo(true); assertThat(responses.getContent().get(i).getGroupId()).isEqualTo( problem.getStudyGroup().getId()); + assertThat(responses.getContent().getFirst().getProblemId()).isEqualTo(problem.getId()); } } @Test - @DisplayName("마감 된 나의 풀이 전체 조회 성공") + @DisplayName("나의 풀이 전체 조회 성공// 마감 된") void getMySolutionsExpired() { + // given Pageable pageable = PageRequest.of(0, 10); List expired = new ArrayList<>(); @@ -759,14 +767,15 @@ void getMySolutionsExpired() { comments.subList(i * 10, i * 10 + 10)); } // when - Page responses = solutionService.getMySolutionsExpired(user, null, null, - null, pageable); + Page responses = solutionService.getMySolutionList(user, null, null, + null, null, ProgressCategory.EXPIRED, false, pageable); // then for (int i = 0; i < 5; i++) { assertThat(responses.getContent().get(i).getNickname()).isEqualTo("nickname1"); assertThat(responses.getContent().get(i).getIsRead()).isEqualTo(false); assertThat(responses.getContent().get(i).getGroupId()).isEqualTo( problem2.getStudyGroup().getId()); + assertThat(responses.getContent().getFirst().getProblemId()).isEqualTo(problem1.getId()); } } diff --git a/src/test/java/com/gamzabat/algohub/service/StudyGroupServiceTest.java b/src/test/java/com/gamzabat/algohub/service/StudyGroupServiceTest.java index 5f7ec35e..3c2543e7 100644 --- a/src/test/java/com/gamzabat/algohub/service/StudyGroupServiceTest.java +++ b/src/test/java/com/gamzabat/algohub/service/StudyGroupServiceTest.java @@ -20,6 +20,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockMultipartFile; @@ -32,12 +35,14 @@ import com.gamzabat.algohub.feature.group.ranking.repository.RankingRepository; import com.gamzabat.algohub.feature.group.studygroup.domain.BookmarkedStudyGroup; import com.gamzabat.algohub.feature.group.studygroup.domain.GroupMember; +import com.gamzabat.algohub.feature.group.studygroup.domain.JoinRequest; import com.gamzabat.algohub.feature.group.studygroup.domain.StudyGroup; import com.gamzabat.algohub.feature.group.studygroup.dto.BookmarkStatus; import com.gamzabat.algohub.feature.group.studygroup.dto.CreateGroupRequest; import com.gamzabat.algohub.feature.group.studygroup.dto.EditGroupRequest; import com.gamzabat.algohub.feature.group.studygroup.dto.EditGroupVisibilityRequest; import com.gamzabat.algohub.feature.group.studygroup.dto.GetGroupMemberResponse; +import com.gamzabat.algohub.feature.group.studygroup.dto.GetGroupResponse; import com.gamzabat.algohub.feature.group.studygroup.dto.GetGroupSettingResponse; import com.gamzabat.algohub.feature.group.studygroup.dto.GetStudyGroupListsResponse; import com.gamzabat.algohub.feature.group.studygroup.dto.GetStudyGroupResponse; @@ -49,6 +54,7 @@ import com.gamzabat.algohub.feature.group.studygroup.exception.GroupMemberValidationException; import com.gamzabat.algohub.feature.group.studygroup.repository.BookmarkedStudyGroupRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.GroupMemberRepository; +import com.gamzabat.algohub.feature.group.studygroup.repository.JoinRequestRepository; import com.gamzabat.algohub.feature.group.studygroup.repository.StudyGroupRepository; import com.gamzabat.algohub.feature.group.studygroup.service.StudyGroupService; import com.gamzabat.algohub.feature.image.service.ImageService; @@ -69,10 +75,12 @@ @ExtendWith(MockitoExtension.class) class StudyGroupServiceTest { + private final Long groupId = 10L; @InjectMocks private StudyGroupService studyGroupService; @Mock private NotificationService notificationService; + @Mock private StudyGroupRepository studyGroupRepository; @Mock @@ -104,14 +112,16 @@ class StudyGroupServiceTest { @Mock private ObjectProvider studyGroupServiceObjectProvider; @Mock + private JoinRequestRepository joinRequestRepository; + @Mock private ImageService imageService; - private User user, owner, user2, user3; + private User user, owner, user2, user3, requester; private StudyGroup group; private Problem problem1, problem2; private Solution solution1, solution2, solution3; private GroupMember groupMember1, groupMember2, groupMember3; - private final Long groupId = 10L; private GroupMember ownerGroupmember; + private JoinRequest joinRequest; @Captor private ArgumentCaptor groupCaptor; @Captor @@ -127,6 +137,8 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { .role(Role.USER).profileImage("image2").build(); user3 = User.builder().email("email3").password("password").nickname("nickname3") .role(Role.USER).profileImage("image3").build(); + requester = User.builder().email("eamilRequester").password("password").nickname("requester") + .role(Role.USER).profileImage("imageForRequester").build(); group = StudyGroup.builder() .name("name") @@ -191,6 +203,7 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { userField.set(owner, 1L); userField.set(user2, 2L); userField.set(user3, 3L); + userField.set(requester, 4L); Field groupId = StudyGroup.class.getDeclaredField("id"); groupId.setAccessible(true); @@ -201,6 +214,15 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { memberId.set(groupMember1, 100L); memberId.set(groupMember2, 200L); memberId.set(groupMember3, 300L); + //For Join Request Service Test + joinRequest = new JoinRequest(group, requester); + Field requestId = JoinRequest.class.getDeclaredField("id"); + Field requestGroup = JoinRequest.class.getDeclaredField("group"); + requestGroup.setAccessible(true); + requestGroup.set(joinRequest, group); + requestId.setAccessible(true); + requestId.set(joinRequest, 1000L); + } @Test @@ -243,7 +265,8 @@ void joinGroupWithCode() { assertThat(result.getStudyGroup()).isEqualTo(group); assertThat(result.getUser()).isEqualTo(user2); verify(groupMemberRepository, times(1)).save(any(GroupMember.class)); - verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any()); + verify(notificationService, times(1)).sendNotificationToMembers(any(), any(), any(), any(), any(), any(), + any()); } @Test @@ -878,4 +901,47 @@ void getStudyGroupSettings() { assertThat(responses.get(2).status()).isEqualTo("done"); } + @Test + @DisplayName("그룹 검색") + void getStudyGroupSearch() { + //given + List groups = new ArrayList<>(20); + for (int i = 0; i < 10; i++) { + StudyGroup g = StudyGroup.builder() + .name("test" + i) + .introduction("ggg" + i) + .build(); + groups.add(g); + } + for (int i = 0; i < 5; i++) { + StudyGroup g = StudyGroup.builder() + .name("abc" + i) + .introduction("tt" + i) + .build(); + groups.add(g); + } + for (int i = 0; i < 5; i++) { + StudyGroup g = StudyGroup.builder() + .name("aaa" + i) + .introduction("test" + i) + .build(); + groups.add(g); + } + + PageRequest pageable = PageRequest.of(0, 10); + List filtered = groups.stream() + .filter(g -> (g.getName() != null && g.getName().contains("test")) + || (g.getIntroduction() != null && g.getIntroduction().contains("test"))) + .toList(); + Page stub = new PageImpl<>(filtered, pageable, filtered.size()); + + when(studyGroupRepository.findBySearchPattern("test", pageable)) + .thenReturn(stub); + //when + Page result = studyGroupService.getSearchedStudyGroupList("test", pageable); + //then + assertThat(result.getContent().size()).isEqualTo(15); + + } + } \ No newline at end of file