From 317b85b05c3aed611648584f2211fc66dd62beea Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 7 Nov 2024 23:04:12 +0900 Subject: [PATCH 01/45] =?UTF-8?q?refactor:=20ncp=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=ED=8C=8C=EC=9D=BC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-prod-cd.yml | 35 ++++++++++------------- docker-compose.yml | 38 +++++++++++++++++++++++++ src/main/resources/application-prod.yml | 6 ++-- src/main/resources/shema.sql | 1 + 4 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 docker-compose.yml diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml index fd690e7..81b10d6 100644 --- a/.github/workflows/backend-prod-cd.yml +++ b/.github/workflows/backend-prod-cd.yml @@ -7,6 +7,7 @@ on: push: branches: - main + - develop paths: - "src/**" jobs: @@ -33,26 +34,20 @@ jobs: push: true # 이미지를 레지스트리에 푸시 tags: ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} - platforms: linux/arm64 + platforms: linux/amd64 cache-from: type=gha cache-to: type=gha,mode=max - backend-docker-pull-and-run: - needs: [ backend-docker-build-and-push ] - if: ${{ needs.backend-docker-build-and-push.result == 'success' }} - runs-on: [ self-hosted, ec2-runner ] - steps: - - name: Checkout code - uses: actions/checkout@v3 - - name: Setup SSH Key - run: | - echo "${{ secrets.PEM_KEY }}" > pem_key - chmod 600 pem_key - name: WAS인스턴스 접속 및 애플리케이션 실행 - run: | - ssh -i pem_key -o StrictHostKeyChecking=no ${{ secrets.WAS_USERNAME }}@${{ secrets.WAS_PUBLIC_IP }} << EOF - docker rm -f agilehub-backend || true - docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} - docker run -v /var/log/backend:/app/logs \ - --env-file .env \ - -d -p 8080:8080 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} - EOF + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USERNAME }} + password: ${{ secrets.NCP_PASSWORD }} + key: ${{ secrets.NCP_PEM_KEY }} + port: 22 + script: | + docker rm -f agilehub-backend || true + docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} + docker run -v /var/log/backend:/app/logs \ + --env-file .env \ + -d -p 8080:8080 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a89b647 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +# 로컬 DB 구성 +version: '3' + +services: + mysql: + image: mysql:8.0 + container_name: mysql + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: agilehubdb + MYSQL_USER: myuser + MYSQL_PASSWORD: mypassword + ports: + - "0.0.0.0:3306:3306" + volumes: + - ./mysql/data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + networks: + - app-network + restart: always + + redis: + image: redis:7.0 + container_name: redis + ports: + - "0.0.0.0:6379:6379" + volumes: + - ./redis/data:/data + command: redis-server --appendonly yes + networks: + - app-network + restart: always + +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 35a7515..012d6b8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -4,9 +4,9 @@ spring: activate: on-profile: prod datasource: - url: ${AWS_DB_URL} - username: ${AWS_DB_USERNAME} - password: ${AWS_DB_PASSWORD} + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver sql: diff --git a/src/main/resources/shema.sql b/src/main/resources/shema.sql index 2ae6602..1e5509e 100644 --- a/src/main/resources/shema.sql +++ b/src/main/resources/shema.sql @@ -30,6 +30,7 @@ create table issue ( sprint_id bigint, issue_type varchar(31) not null, content varchar(255), + version bigint, title varchar(255), status enum ('DO','PROGRESS','DONE'), label enum ('NONE', 'PLAN', 'DESIGN', 'DEVELOP', 'TEST', 'FEEDBACK'), From 74409d5d0965bdb1fcb3af29c9673b653856948f Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 7 Nov 2024 23:19:56 +0900 Subject: [PATCH 02/45] =?UTF-8?q?refactor:=20docker=20file=20amd=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5e04649..2a95e43 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,25 @@ # Dockerfile # gradle && jdk17 이미지 빌드 - build 별명 -FROM --platform=linux/arm64 gradle:8.3-jdk17-jammy AS build +FROM --platform=linux/amd64 gradle:jdk17-jammy AS build # 작업 디렉토리 /app 생성 WORKDIR /app # 빌드하는데 필요한 build.gradle, settings.gradle 파일 현재 디렉토리로 복사 COPY build.gradle settings.gradle /app/ +COPY gradle /app/gradle + # 그래들 파일이 변경되었을 때만 새롭게 의존패키지 다운로드 받게함. -RUN gradle build -x test --parallel --continue > /dev/null 2>&1 || true +# 의존성 다운로드 +RUN gradle dependencies --no-daemon # 소스코드파일 /app 작업 디렉토리로 복사 COPY . /app # Gradle 빌드를 실행하여 JAR 파일 생성 -ENV JWT_SECRET 12341234 -ENV REDIS_HOST redis -ENV MAIL_USERNAME mail -ENV MAIL_PASSWORD 12341234 RUN gradle build -x test --parallel -FROM --platform=linux/arm64/v8 eclipse-temurin:17.0.10_7-jre +FROM --platform=linux/amd64 eclipse-temurin:21.0.4_7-jre-jammy WORKDIR /app From b791f52987ba458e9be0a2417e6dfe2feda98482 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Thu, 7 Nov 2024 23:21:12 +0900 Subject: [PATCH 03/45] =?UTF-8?q?refactor:=20=EC=9D=B4=EC=8A=88=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=EC=8B=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=EB=AC=B8=EC=A0=9C=20=EB=82=99=EA=B4=80=EC=A0=81?= =?UTF-8?q?=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=ED=95=B4=EA=B2=B0=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/exception/GeneralException.java | 7 +- .../global/header/status/ErrorStatus.java | 22 +-- .../issue/IssueUpdateLoggingAspect.java | 41 +++++ .../issue/controller/IssueController.java | 15 +- .../agilehub/issue/domain/Issue.java | 11 +- .../issue/service/command/IssueService.java | 12 +- .../agilehub/project/domain/Project.java | 2 + .../project/service/ProjectQueryService.java | 7 +- src/main/resources/logback-spring.xml | 5 +- .../service/IssueUpdateConcurrencyTest.java | 158 ++++++++++++++++++ 10 files changed, 254 insertions(+), 26 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java diff --git a/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java b/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java index b13faea..90112c5 100644 --- a/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java +++ b/src/main/java/dynamicquad/agilehub/global/exception/GeneralException.java @@ -2,14 +2,17 @@ import dynamicquad.agilehub.global.header.ReasonDto; import dynamicquad.agilehub.global.header.status.BaseStatus; -import lombok.AllArgsConstructor; import lombok.Getter; @Getter -@AllArgsConstructor public class GeneralException extends RuntimeException { private final BaseStatus status; + public GeneralException(BaseStatus status) { + super(status.getReason().getMessage()); + this.status = status; + } + public ReasonDto getErrorReason() { return this.status.getReason(); } diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java index e9a49dc..c7c6280 100644 --- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java +++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java @@ -60,7 +60,9 @@ public enum ErrorStatus implements BaseStatus { // Email Error EMAIL_NOT_SENT(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_5001", "이메일이 정상적으로 송신되지 않았습니다."), INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."), - ; + + // Optimistic Lock Exception + OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."); private final HttpStatus httpStatus; private final String code; @@ -69,19 +71,19 @@ public enum ErrorStatus implements BaseStatus { @Override public ReasonDto getReason() { return ReasonDto.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); + .message(message) + .code(code) + .isSuccess(false) + .build(); } @Override public ReasonDto getReasonHttpStatus() { return ReasonDto.builder() - .status(httpStatus) - .message(message) - .code(code) - .isSuccess(false) - .build(); + .status(httpStatus) + .message(message) + .code(code) + .isSuccess(false) + .build(); } } diff --git a/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java b/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java new file mode 100644 index 0000000..eed54d0 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/IssueUpdateLoggingAspect.java @@ -0,0 +1,41 @@ +package dynamicquad.agilehub.issue; + +import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +@Slf4j +@Aspect +@Component +public class IssueUpdateLoggingAspect { + + private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS"); + + @Around("execution(* dynamicquad.agilehub.issue.service.command.IssueService.updateIssue(..)) && args(key, issueId, request, authMember)") + public Object logIssueUpdate(ProceedingJoinPoint joinPoint, + String key, Long issueId, IssueRequestDto.EditIssue request, AuthMember authMember) + throws Throwable { + + String threadName = Thread.currentThread().getName(); + String requestContent = request.getContent(); + String currentTime = LocalDateTime.now().format(formatter); + + log.warn("[동시성 추적 - {}] Thread: {}, 이슈: {}, 사용자: {}, 수정 요청 내용: '{}'", + currentTime, + threadName, + issueId, + authMember.getId(), + requestContent); + Object result; + + result = joinPoint.proceed(); + + return result; + } +} diff --git a/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java b/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java index 9f70a5b..df6ec4d 100644 --- a/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java +++ b/src/main/java/dynamicquad/agilehub/issue/controller/IssueController.java @@ -2,6 +2,7 @@ import dynamicquad.agilehub.global.auth.model.Auth; import dynamicquad.agilehub.global.header.CommonResponse; +import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.global.header.status.SuccessStatus; import dynamicquad.agilehub.issue.dto.IssueRequestDto; import dynamicquad.agilehub.issue.dto.IssueResponseDto.IssueAndSubIssueDetail; @@ -17,8 +18,10 @@ import java.net.URI; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @@ -71,8 +74,16 @@ public ResponseEntity editProjectIssue(@Valid @ModelAttribute IssueRequestDto @PathVariable("key") String key, @PathVariable("issueId") Long issueId, @Auth AuthMember authMember) { - issueService.updateIssue(key, issueId, request, authMember); - return ResponseEntity.noContent().build(); + try { + issueService.updateIssue(key, issueId, request, authMember); + return ResponseEntity.ok().build(); + } catch (ObjectOptimisticLockingFailureException e) { + String viewIssueUrl = "/projects/" + key + "/issues/" + issueId; + + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(CommonResponse.of(ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION, viewIssueUrl)); + } + } diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java index e616275..e785e96 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java @@ -1,6 +1,7 @@ package dynamicquad.agilehub.issue.domain; import dynamicquad.agilehub.comment.domain.Comment; +import dynamicquad.agilehub.global.domain.BaseEntity; import dynamicquad.agilehub.issue.dto.IssueRequestDto; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.project.domain.Project; @@ -21,6 +22,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import jakarta.persistence.Version; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -36,7 +38,7 @@ @DiscriminatorColumn(name = "issue_type") @Table(name = "issue") @Entity -public abstract class Issue { +public abstract class Issue extends BaseEntity { @Id @Include @@ -44,6 +46,9 @@ public abstract class Issue { @Column(name = "issue_id") private Long id; + @Version + private Long version; + private String title; private String content; @@ -98,6 +103,10 @@ protected void updateIssue(IssueRequestDto.EditIssue request, Member assignee) { this.assignee = assignee; } + public void updateContent(String content) { + this.content = content; + } + public void updateStatus(IssueStatus updateStatus) { this.status = updateStatus; diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java index 515edbb..31583a4 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java @@ -19,12 +19,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Slf4j -@Transactional(readOnly = true) public class IssueService { private final ProjectQueryService projectQueryService; @@ -44,11 +44,9 @@ public Long createIssue(String key, IssueRequestDto.CreateIssue request, AuthMem } - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public void updateIssue(String key, Long issueId, IssueRequestDto.EditIssue request, AuthMember authMember) { - Project project = validateMemberInProject(key, authMember); - Issue issue = issueValidator.findIssue(issueId); issueValidator.validateIssueInProject(project.getId(), issueId); issueValidator.validateEqualsIssueType(issue, request.getType()); @@ -89,10 +87,12 @@ public void updateIssuePeriod(String key, Long issueId, AuthMember authMember, E if (issue instanceof Epic epic) { epic.updatePeriod(request.getStartDate(), request.getEndDate()); return; - } else if (issue instanceof Story story) { + } + else if (issue instanceof Story story) { story.updatePeriod(request.getStartDate(), request.getEndDate()); return; - } else if (issue instanceof Task task) { + } + else if (issue instanceof Task task) { task.updatePeriod(request.getStartDate(), request.getEndDate()); return; } diff --git a/src/main/java/dynamicquad/agilehub/project/domain/Project.java b/src/main/java/dynamicquad/agilehub/project/domain/Project.java index 6907cd9..ce4158b 100644 --- a/src/main/java/dynamicquad/agilehub/project/domain/Project.java +++ b/src/main/java/dynamicquad/agilehub/project/domain/Project.java @@ -14,12 +14,14 @@ import lombok.EqualsAndHashCode.Include; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.ToString; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) @Table(name = "project") @Entity +@ToString public class Project extends BaseEntity { @Id @Include diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java index 709144b..ff41af7 100644 --- a/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java +++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectQueryService.java @@ -31,18 +31,19 @@ public List getProjects(Long memberId) { } public Project findProject(String originKey) { + log.info("findProject: {}", originKey); return projectRepository.findByKey(originKey) - .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); + .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); } public Long findProjectId(String originKey) { return projectRepository.findIdByKey(originKey) - .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); + .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); } public Project findProjectById(long projectId) { return projectRepository.findById(projectId) - .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); + .orElseThrow(() -> new GeneralException(ErrorStatus.PROJECT_NOT_FOUND)); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ed1de47..5162435 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,8 +3,9 @@ + + value="%d{yyyy-MM-dd HH:mm:ss.SSS} [${APP_NAME}] [%thread] %highlight(%5level) %cyan(%logger) - %msg%n"/> @@ -27,7 +28,7 @@ - + diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java new file mode 100644 index 0000000..a4ede46 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java @@ -0,0 +1,158 @@ +package dynamicquad.agilehub.issue.service; + +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueLabel; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.issue.service.command.IssueService; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.member.repository.MemberRepository; +import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRepository; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.project.domain.ProjectRepository; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +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.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class IssueUpdateConcurrencyTest { + + @Autowired + private IssueService issueService; + + @Autowired + private ProjectRepository projectRepository; + @Autowired + private MemberProjectRepository memberProjectRepository; + @Autowired + private MemberRepository memberRepository; + + @Autowired + private IssueRepository issueRepository; + + Project testProject; + ArrayList authMembers; + + @BeforeEach + void init() { + testProject = createTestProject(); + projectRepository.save(testProject); + System.out.println( + "잘 저장 = " + projectRepository.findByKey(testProject.getKey()).orElseThrow()); + authMembers = new ArrayList<>(); + } + + @Test + @DisplayName("동시에 2명의 사용자가 하나의 이슈에 편집을 했을 때") + void concurrentUpdateTest() { + // given + createMembers(2); + Issue testIssue = createTestIssue(); + + // when + CompletableFuture userA = CompletableFuture.runAsync(() -> { + issueService.updateIssue(testProject.getKey(), testIssue.getId(), + createEditIssueByEditContent("사용자 A가 수정한 내용"), authMembers.get(0)); + }).exceptionally(e -> { + Throwable cause = e.getCause(); + System.out.println("발생한 예외: " + cause.getClass().getName()); + System.out.println("예외 메시지: " + cause.getMessage()); + //e.printStackTrace(); + System.out.println("USER A 에러 발생"); + return null; + }); + CompletableFuture userB = CompletableFuture.runAsync(() -> { + + issueService.updateIssue(testProject.getKey(), testIssue.getId(), + createEditIssueByEditContent("사용자 B가 수정한 내용"), authMembers.get(1)); + + }).exceptionally(e -> { + Throwable cause = e.getCause(); + System.out.println("발생한 예외: " + cause.getClass().getName()); + System.out.println("예외 메시지: " + cause.getMessage()); + System.out.println("USER B 에러 발생"); + + return null; + }); + + // 두 작업이 모두 완료될 때까지 대기 + CompletableFuture.allOf(userA, userB).join(); + + // Then + Issue updatedIssue = issueRepository.findById(testIssue.getId()).get(); + System.out.println("최종 내용: " + updatedIssue.getContent()); + } + + private void createMembers(int memberCount) { + for (int j = 0; j < memberCount; j++) { + Member member = Member.builder() + .name("사용자" + j) + .build(); + memberRepository.save(member); + memberProjectRepository.save(MemberProject.builder() + .member(member) + .project(testProject) + .role(MemberProjectRole.ADMIN) + .build()); + authMembers.add(AuthMember.builder() + .id(member.getId()) + .name(member.getName()) + .build()); + } + } + + private Project createTestProject() { + return Project.builder() + .name("테스트 프로젝트") + .key("TEST") + .build(); + } + + + private Issue createTestIssue() { + Issue testIssue = Epic.builder() + .title("테스트용 이슈") + .content( + """ +

테스트용 이슈

+

이슈 내용입니다.

+ """ + ) + .number(1) + .status(IssueStatus.DO) + .assignee(null) + .label(IssueLabel.TEST) + .project(testProject) + .startDate(LocalDate.of(2024, 11, 6)) + .endDate(LocalDate.of(2024, 11, 9)) + .build(); + + issueRepository.save(testIssue); + return testIssue; + } + + private IssueRequestDto.EditIssue createEditIssueByEditContent(String editContent) { + return IssueRequestDto.EditIssue.builder() + .title("테스트용 이슈") + .content(editContent) + .type(IssueType.EPIC) + .status(IssueStatus.DO) + .label(IssueLabel.TEST) + .startDate(LocalDate.of(2024, 11, 6)) + .endDate(LocalDate.of(2024, 11, 9)) + .build(); + } +} From 9f367c9cdbec93d954bf9bc3798cd0a6149a856b Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 7 Nov 2024 23:45:54 +0900 Subject: [PATCH 04/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=9A=A9=EC=9D=B4=ED=95=98=EA=B2=8C=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20JWT=20=EB=B0=8F=20OAuth=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/MockSecurityContext.java | 46 ++++++ .../global/auth/filter/JwtAuthFilter.java | 9 +- .../auth/util/MemberArgumentResolver.java | 31 +++-- .../global/config/SpringSecurityConfig.java | 131 +++++++++++------- 4 files changed, 152 insertions(+), 65 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java diff --git a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java new file mode 100644 index 0000000..1086dc3 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java @@ -0,0 +1,46 @@ +package dynamicquad.agilehub.global.auth; + +import dynamicquad.agilehub.global.auth.model.SecurityMember; +import dynamicquad.agilehub.member.domain.Member; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import java.util.Collections; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +@Component +public class MockSecurityContext { + @PostConstruct + public void init() { + // 테스트용 Member 객체 생성 + Member mockMember = Member.builder() + .name("테스트사용자") + .profileImageUrl("http://example.com/test.jpg") + .build(); + + // SecurityMember 생성 + SecurityMember securityMember = new SecurityMember(mockMember); + + // Authentication 객체 생성 + Authentication authentication = new UsernamePasswordAuthenticationToken( + securityMember, + null, + Collections.emptyList() // 필요한 경우 권한 추가 + ); + + // SecurityContext에 Authentication 설정 + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } + + // 테스트 종료 시 정리 + @PreDestroy + public void cleanup() { + SecurityContextHolder.clearContext(); + } + +} diff --git a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java index 3d31354..775c24a 100644 --- a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java +++ b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java @@ -18,11 +18,10 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -@Component +//@Component -> 필터비활성화 @RequiredArgsConstructor @Slf4j public class JwtAuthFilter extends OncePerRequestFilter { @@ -48,12 +47,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String accessToken = token.get(); if (jwtUtil.verifyToken(accessToken)) { saveAuthentication(accessToken); - } else { + } + else { String reissuedAccessToken = reissueAccessToken(accessToken); if (StringUtils.hasText(reissuedAccessToken)) { saveAuthentication(reissuedAccessToken); response.setHeader(jwtUtil.getAccessHeader(), reissuedAccessToken); - } else { + } + else { throw new JwtException("Refresh Token is expired"); } } diff --git a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java index d871c78..50f7281 100644 --- a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java +++ b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java @@ -1,12 +1,8 @@ package dynamicquad.agilehub.global.auth.util; import dynamicquad.agilehub.global.auth.model.Auth; -import dynamicquad.agilehub.global.auth.model.SecurityMember; -import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import org.springframework.core.MethodParameter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -19,18 +15,31 @@ public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType().equals(AuthMember.class); } +// @Override +// public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, +// NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { +// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); +// +// SecurityMember principal = (SecurityMember) authentication.getPrincipal(); +// Member member = principal.getMember(); +// +// return AuthMember.builder() +// .id(member.getId()) +// .name(member.getName()) +// .profileImageUrl(member.getProfileImageUrl()) +// .build(); +// } + + @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - SecurityMember principal = (SecurityMember) authentication.getPrincipal(); - Member member = principal.getMember(); + // 테스트용 유저1 -> 모든 API에 해당 유저만 진입됨 -> API 진입 유저 1 이 여러번 생성과 수정과 조회 하는 것도 의미있다고 봅니다 return AuthMember.builder() - .id(member.getId()) - .name(member.getName()) - .profileImageUrl(member.getProfileImageUrl()) + .id(1L) + .name("User1") + .profileImageUrl("adada") .build(); } } diff --git a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java index 5a59299..71f1f79 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java @@ -1,79 +1,110 @@ package dynamicquad.agilehub.global.config; -import dynamicquad.agilehub.global.auth.filter.JwtAuthFilter; -import dynamicquad.agilehub.global.auth.filter.JwtExceptionFilter; -import dynamicquad.agilehub.global.auth.filter.OAuth2SuccessHandler; -import dynamicquad.agilehub.global.auth.repository.CustomAuthorizationRequestRepository; -import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService; -import java.util.Collections; -import java.util.List; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.filter.OncePerRequestFilter; @Configuration @RequiredArgsConstructor @Slf4j public class SpringSecurityConfig { - private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final JwtAuthFilter jwtAuthFilter; - - private final CustomOAuth2UserService customOAuth2UserService; +// private final OAuth2SuccessHandler oAuth2SuccessHandler; +// private final JwtAuthFilter jwtAuthFilter; +// +// private final CustomOAuth2UserService customOAuth2UserService; +// +// @Bean +// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { +// +// // csrf disable 처리 : 추후 설정 변경 필요 +// http +// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) +// .cors(cors -> cors.configurationSource(request -> { +// CorsConfiguration config = new CorsConfiguration(); +// config.setAllowedOriginPatterns(Collections.singletonList("*")); +// config.setAllowedMethods(Collections.singletonList("*")); +// config.setAllowedHeaders(Collections.singletonList("*")); +// config.setAllowCredentials(true); +// config.setExposedHeaders(List.of("Authorization")); +// config.setMaxAge(3600L); +// return config; +// })) +// .csrf(AbstractHttpConfigurer::disable) +// .formLogin(AbstractHttpConfigurer::disable) +// .httpBasic(AbstractHttpConfigurer::disable) +// .logout(AbstractHttpConfigurer::disable) +// // requestMatchers 설정 +// .authorizeHttpRequests(requests -> requests +// .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**", +// "/actuator/**", +// "/favicon.ico") +// .permitAll() +// .anyRequest().authenticated() +// ) +// +// // oauth2 설정 +// .oauth2Login(customizer -> customizer +// .authorizationEndpoint(authorization -> authorization +// .authorizationRequestRepository(new CustomAuthorizationRequestRepository())) +// .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) +// .successHandler(oAuth2SuccessHandler) +// ) +// +// // jwt 설정 +// .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) +// .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class); +// +// return http.build(); +// } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - - // csrf disable 처리 : 추후 설정 변경 필요 + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // 기존의 모든 필터 비활성화 http - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .cors(cors -> cors.configurationSource(request -> { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOriginPatterns(Collections.singletonList("*")); - config.setAllowedMethods(Collections.singletonList("*")); - config.setAllowedHeaders(Collections.singletonList("*")); - config.setAllowCredentials(true); - config.setExposedHeaders(List.of("Authorization")); - config.setMaxAge(3600L); - return config; - })) - .csrf(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .httpBasic(AbstractHttpConfigurer::disable) - .logout(AbstractHttpConfigurer::disable) - // requestMatchers 설정 - .authorizeHttpRequests(requests -> requests - .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**", - "/actuator/**", - "/favicon.ico") - .permitAll() - .anyRequest().authenticated() - ) - - // oauth2 설정 - .oauth2Login(customizer -> customizer - .authorizationEndpoint(authorization -> authorization - .authorizationRequestRepository(new CustomAuthorizationRequestRepository())) - .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) - .successHandler(oAuth2SuccessHandler) - ) + .csrf(csrf -> csrf.disable()) // CSRF 보호 비활성화 + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 비활성화 + .authorizeHttpRequests(auth -> auth + .requestMatchers("/**").permitAll() // 모든 요청 허용 + ) + .oauth2Login(oauth2 -> oauth2.disable()) // OAuth2 로그인 비활성화 + .formLogin(form -> form.disable()) // 폼 로그인 비활성화 + .httpBasic(basic -> basic.disable()) // HTTP Basic 인증 비활성화 + .logout(logout -> logout.disable()); // 로그아웃 기능 비활성화 - // jwt 설정 - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class); + // JWT 필터 제거 (만약 커스텀 필터가 있다면) + http.addFilterBefore(new OncePerRequestFilter() { + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + filterChain.doFilter(request, response); + } + }, UsernamePasswordAuthenticationFilter.class); return http.build(); } + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring() + .requestMatchers("/**"); // 모든 경로에 대해 시큐리티 무시 + } + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); From a268afec9d38d9ff39aa0d21b63ef621c07f8549 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 7 Nov 2024 23:46:44 +0900 Subject: [PATCH 05/45] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=9A=A9=EC=9D=B4=ED=95=98=EA=B2=8C=20=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=8B=9C=ED=81=90=EB=A6=AC=ED=8B=B0?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EB=B0=8F=20JWT=20=EB=B0=8F=20OAuth=20?= =?UTF-8?q?=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/auth/MockSecurityContext.java | 46 ------------------- 1 file changed, 46 deletions(-) delete mode 100644 src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java diff --git a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java b/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java deleted file mode 100644 index 1086dc3..0000000 --- a/src/main/java/dynamicquad/agilehub/global/auth/MockSecurityContext.java +++ /dev/null @@ -1,46 +0,0 @@ -package dynamicquad.agilehub.global.auth; - -import dynamicquad.agilehub.global.auth.model.SecurityMember; -import dynamicquad.agilehub.member.domain.Member; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import java.util.Collections; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; - -@Component -public class MockSecurityContext { - @PostConstruct - public void init() { - // 테스트용 Member 객체 생성 - Member mockMember = Member.builder() - .name("테스트사용자") - .profileImageUrl("http://example.com/test.jpg") - .build(); - - // SecurityMember 생성 - SecurityMember securityMember = new SecurityMember(mockMember); - - // Authentication 객체 생성 - Authentication authentication = new UsernamePasswordAuthenticationToken( - securityMember, - null, - Collections.emptyList() // 필요한 경우 권한 추가 - ); - - // SecurityContext에 Authentication 설정 - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); - } - - // 테스트 종료 시 정리 - @PreDestroy - public void cleanup() { - SecurityContextHolder.clearContext(); - } - -} From 666b31c26d52e1d0ace797e690f9d5175bc3ed75 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sat, 9 Nov 2024 00:06:52 +0900 Subject: [PATCH 06/45] =?UTF-8?q?refactor:=20=EB=B9=84=EA=B4=80=EC=A0=81?= =?UTF-8?q?=20=EB=9D=BD=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EC=8A=88=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=83=9D=EC=84=B1=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/dummy/DummyDataLoader.java | 8 +- .../global/header/status/ErrorStatus.java | 3 +- .../agilehub/issue/aspect/Retry.java | 11 ++ .../agilehub/issue/aspect/RetryAspect.java | 41 +++++ .../agilehub/issue/domain/Epic.java | 2 +- .../agilehub/issue/domain/Issue.java | 9 +- .../issue/domain/ProjectIssueSequence.java | 43 +++++ .../agilehub/issue/domain/Story.java | 2 +- .../agilehub/issue/domain/Task.java | 2 +- .../agilehub/issue/dto/IssueResponseDto.java | 12 +- .../issue/dto/backlog/EpicResponseDto.java | 2 +- .../issue/dto/backlog/StoryResponseDto.java | 2 +- .../issue/dto/backlog/TaskResponseDto.java | 2 +- .../issue/repository/IssueRepository.java | 3 - .../ProjectIssueSequenceRepository.java | 14 ++ .../service/command/IssueNumberGenerator.java | 34 ++++ .../issue/service/command/IssueService.java | 2 + .../issue/service/factory/EpicFactory.java | 16 +- .../issue/service/factory/StoryFactory.java | 10 +- .../issue/service/factory/TaskFactory.java | 9 +- .../project/service/ProjectService.java | 7 + src/main/resources/shema.sql | 11 +- .../agilehub/issue/IssueConcurrencyTest.java | 164 ++++++++++++++++++ .../agilehub/issue/domain/EpicTest.java | 2 +- .../issue/domain/IssueRepositoryTest.java | 43 ----- .../agilehub/issue/domain/StoryTest.java | 4 +- .../issue/service/IssueServiceTest.java | 2 +- .../service/IssueUpdateConcurrencyTest.java | 2 +- .../service/factory/EpicFactoryTest.java | 9 +- .../service/factory/TaskFactoryTest.java | 10 +- .../sprint/SprintQueryServiceTest.java | 6 +- 31 files changed, 393 insertions(+), 94 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java create mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java create mode 100644 src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java create mode 100644 src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java create mode 100644 src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java diff --git a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java index 42f2e92..3a867a4 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java +++ b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java @@ -4,9 +4,9 @@ import dynamicquad.agilehub.dummy.bulkRepository.MemberBulkRepository; import dynamicquad.agilehub.dummy.bulkRepository.MemberProjectBulkRepository; import dynamicquad.agilehub.dummy.bulkRepository.ProjectBulkRepository; +import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Story; import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.member.domain.Member; @@ -98,7 +98,7 @@ private void epicBulk() { epics.add(Epic.builder() .title("에픽" + i) .content("에픽 내용" + i) - .number((int) i) + .number("") .status(IssueStatus.DO) .startDate(LocalDate.of(2021, 1, 1)) .endDate(LocalDate.of(2021, 10, 23)) @@ -126,7 +126,7 @@ private void storyBulk() { stories.add(Story.builder() .title("스토리" + i) .content("스토리 내용" + i) - .number((int) i) + .number("") .status(IssueStatus.DO) .startDate(LocalDate.of(2021, 1, 1)) .endDate(LocalDate.of(2021, 10, 23)) @@ -156,7 +156,7 @@ private void taskBulk() { tasks.add(Task.builder() .title("태스크" + i) .content("태스크 내용" + i) - .number((int) i) + .number("") .status(IssueStatus.DO) .build()); } diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java index c7c6280..62ffcd4 100644 --- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java +++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java @@ -62,7 +62,8 @@ public enum ErrorStatus implements BaseStatus { INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."), // Optimistic Lock Exception - OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."); + OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."), + OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER(HttpStatus.LOCKED, "LOCK_4001", "이슈 번호 생성 실패. 다시 시도해주세요."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java new file mode 100644 index 0000000..1adbd20 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java @@ -0,0 +1,11 @@ +package dynamicquad.agilehub.issue.aspect; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Retry { +} diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java new file mode 100644 index 0000000..6006ae9 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java @@ -0,0 +1,41 @@ +package dynamicquad.agilehub.issue.aspect; + +import static dynamicquad.agilehub.global.header.status.ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER; + +import dynamicquad.agilehub.global.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; + +@Aspect +@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션보다 더 낮은 순서로 실행 +@Component +@Slf4j +public class RetryAspect { + private static final int MAX_RETRIES = 5; + + @Around("@annotation(dynamicquad.agilehub.issue.aspect.Retry)") + public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { + int attempts = 0; + while (attempts < MAX_RETRIES) { + try { + log.info("시작 시도: {}", attempts + 1); + return joinPoint.proceed(); + } catch (ObjectOptimisticLockingFailureException e) { + attempts++; + if (attempts == MAX_RETRIES) { + log.error("Failed after {} attempts", MAX_RETRIES, e); + throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER); + } + log.warn("Retry attempt {} of {}", attempts, MAX_RETRIES); + Thread.sleep(100); // 재시도 전 잠시 대기 + } + } + throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER); + } +} diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java b/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java index 2a6c346..47e0b2a 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java @@ -30,7 +30,7 @@ public class Epic extends Issue { private List stories = new ArrayList<>(); @Builder - private Epic(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee, + private Epic(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, Project project, LocalDate startDate, LocalDate endDate) { super(title, content, number, status, label, assignee, project); this.startDate = startDate; diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java index e785e96..a30143f 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java @@ -22,7 +22,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; -import jakarta.persistence.Version; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -46,14 +45,14 @@ public abstract class Issue extends BaseEntity { @Column(name = "issue_id") private Long id; - @Version - private Long version; +// @Version +// private Long version; private String title; private String content; - private int number; + private String number; @Enumerated(EnumType.STRING) private IssueStatus status; @@ -84,7 +83,7 @@ public void setSprint(Sprint newSprint) { } - protected Issue(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee, + protected Issue(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, Project project) { this.title = title; this.content = content; diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java b/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java new file mode 100644 index 0000000..23d3280 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/domain/ProjectIssueSequence.java @@ -0,0 +1,43 @@ +package dynamicquad.agilehub.issue.domain; + +import dynamicquad.agilehub.global.domain.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "project_issue_sequence") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectIssueSequence extends BaseEntity { + @Id + @Column(name = "project_key") + private String projectKey; + + @Column(name = "last_number") + private int lastNumber; + +// @Version +// private Long version; + + public ProjectIssueSequence(String projectKey) { + this.projectKey = projectKey; + this.lastNumber = 0; + } + + public void updateLastNumber(int lastNumber) { + this.lastNumber = lastNumber; + } + + public int getNextNumber() { + return ++lastNumber; + } + + public int decrement() { + return lastNumber--; + } +} diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java b/src/main/java/dynamicquad/agilehub/issue/domain/Story.java index d426d47..262dd83 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Story.java @@ -38,7 +38,7 @@ public class Story extends Issue { private List tasks = new ArrayList<>(); @Builder - private Story(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee, + private Story(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, Project project, int storyPoint, LocalDate startDate, LocalDate endDate, Epic epic) { super(title, content, number, status, label, assignee, project); diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java b/src/main/java/dynamicquad/agilehub/issue/domain/Task.java index 5e96891..b92a2a8 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Task.java @@ -31,7 +31,7 @@ public class Task extends Issue { private Story story; @Builder - private Task(String title, String content, int number, IssueStatus status, IssueLabel label, Member assignee, + private Task(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, Project project, LocalDate startDate, LocalDate endDate, Story story) { super(title, content, number, status, label, assignee, project); this.story = story; diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java index e739723..fd17fdc 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java @@ -57,7 +57,7 @@ public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto a IssueType issueType) { IssueDetail issueDetail = IssueDetail.builder() .issueId(issue.getId()) - .key(issue.getProject().getKey() + "-" + issue.getNumber()) + .key(issue.getNumber()) .title(issue.getTitle()) .type(issueType.toString()) .status(String.valueOf(issue.getStatus())) @@ -71,12 +71,14 @@ public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto a Epic epic = Epic.extractFromIssue(issue); issueDetail.startDate = epic.getStartDate() == null ? "" : epic.getStartDate().toString(); issueDetail.endDate = epic.getEndDate() == null ? "" : epic.getEndDate().toString(); - } else if (IssueType.STORY.equals(issueType)) { + } + else if (IssueType.STORY.equals(issueType)) { Story story = Story.extractFromIssue(issue); issueDetail.startDate = story.getStartDate() == null ? "" : story.getStartDate().toString(); issueDetail.endDate = story.getEndDate() == null ? "" : story.getEndDate().toString(); - } else if (IssueType.TASK.equals(issueType)) { + } + else if (IssueType.TASK.equals(issueType)) { Task task = Task.extractFromIssue(issue); issueDetail.startDate = task.getStartDate() == null ? "" : task.getStartDate().toString(); @@ -133,7 +135,7 @@ public SubIssueDetail() { public static SubIssueDetail from(Issue issue, IssueType issueType, AssigneeDto assigneeDto) { return SubIssueDetail.builder() .issueId(issue.getId()) - .key(issue.getProject().getKey() + "-" + issue.getNumber()) + .key(issue.getNumber()) .status(String.valueOf(issue.getStatus())) .label(String.valueOf(issue.getLabel())) .type(issueType.toString()) @@ -165,7 +167,7 @@ public static IssueResponseDto.ReadSimpleIssue from(Issue issue, String projectK .status(String.valueOf(issue.getStatus())) .label(String.valueOf(issue.getLabel())) .assignee(assignee) - .key(projectKey + "-" + issue.getNumber()) + .key(issue.getNumber()) .build(); } diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java index 15d7faf..eb3b4d0 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java @@ -29,7 +29,7 @@ public static EpicDetailForBacklog from(Epic epic, String key, AssigneeDto assig return EpicDetailForBacklog.builder() .id(epic.getId()) .title(epic.getTitle()) - .key(key + "-" + epic.getNumber()) + .key(epic.getNumber()) .status(epic.getStatus().toString()) .label(String.valueOf(epic.getLabel())) .type(IssueType.EPIC.toString()) diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java index 9a3d887..4d45df3 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java @@ -32,7 +32,7 @@ public static StoryDetailForBacklog from(Story story, String projectKey, Long pa return StoryDetailForBacklog.builder() .id(story.getId()) .title(story.getTitle()) - .key(projectKey + "-" + story.getNumber()) + .key(story.getNumber()) .status(String.valueOf(story.getStatus())) .label(String.valueOf(story.getLabel())) .type(IssueType.STORY.toString()) diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java index 564a829..7852c56 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java @@ -32,7 +32,7 @@ public static TaskDetailForBacklog from(Task task, String projectKey, Long paren return TaskDetailForBacklog.builder() .id(task.getId()) .title(task.getTitle()) - .key(projectKey + "-" + task.getNumber()) + .key(task.getNumber()) .status(String.valueOf(task.getStatus())) .label(String.valueOf(task.getLabel())) .type(IssueType.TASK.toString()) diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java index 34c77e8..85f097b 100644 --- a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java +++ b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java @@ -14,9 +14,6 @@ @Repository public interface IssueRepository extends JpaRepository { - @Query("select count(*) from Issue i join i.project p where p.key = :key") - Long countByProjectKey(String key); - @Query(value = "select issue_type from issue i where i.issue_id = :id", nativeQuery = true) Optional findIssueTypeById(@Param("id") Long id); diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java new file mode 100644 index 0000000..59afa6f --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/repository/ProjectIssueSequenceRepository.java @@ -0,0 +1,14 @@ +package dynamicquad.agilehub.issue.repository; + +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; +import jakarta.persistence.LockModeType; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; + +public interface ProjectIssueSequenceRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findByProjectKey(String projectKey); + +} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java new file mode 100644 index 0000000..ce2b80e --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -0,0 +1,34 @@ +package dynamicquad.agilehub.issue.service.command; + +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; +import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class IssueNumberGenerator { + + private final ProjectIssueSequenceRepository issueSequenceRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + //@Retry + public String generate(String projectKey) { + ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) + .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); + + sequence.updateLastNumber(sequence.getNextNumber()); + + return projectKey + "-" + sequence.getLastNumber(); + } + + @Transactional + public void decrement(String projectKey) { + ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) + .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); + + sequence.decrement(); + } +} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java index 31583a4..d085949 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java @@ -31,6 +31,7 @@ public class IssueService { private final IssueFactoryProvider issueFactoryProvider; private final IssueValidator issueValidator; private final MemberProjectService memberProjectService; + private final IssueNumberGenerator issueNumberGenerator; private final IssueRepository issueRepository; @@ -63,6 +64,7 @@ public void deleteIssue(String key, Long issueId, AuthMember authMember) { Issue issue = issueValidator.findIssue(issueId); issueRepository.delete(issue); + issueNumberGenerator.decrement(key); } @Transactional diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java index 4886694..0d5d5e2 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java @@ -10,6 +10,7 @@ import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.issue.repository.StoryRepository; import dynamicquad.agilehub.issue.service.command.ImageService; +import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.AssigneeDto; import dynamicquad.agilehub.member.service.MemberService; @@ -30,6 +31,7 @@ public class EpicFactory implements IssueFactory { private final IssueRepository issueRepository; private final StoryRepository storyRepository; private final ImageService imageService; + private final IssueNumberGenerator issueNumberGenerator; private final MemberService memberService; @@ -39,14 +41,20 @@ public class EpicFactory implements IssueFactory { @Transactional @Override public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ] - // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ] - int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1); + // 이슈 번호 생성 + // 리트라이 3번 + + String issueNumber = issueNumberGenerator.generate(project.getKey()); + + // 멤버를 찾기 Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); Epic epic = toEntity(request, project, issueNumber, assignee); + // 이슈 저장 issueRepository.save(epic); + + // S3에 이미지 저장 if (request.getFiles() != null && !request.getFiles().isEmpty()) { log.info("uploading images"); imageService.saveImages(epic, request.getFiles(), WORKING_DIRECTORY); @@ -104,7 +112,7 @@ private SubIssueDetail getStoryToSubIssue(Story story) { } - private Epic toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee) { + private Epic toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee) { return Epic.builder() .title(request.getTitle()) .content(request.getContent()) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java index 5b91dc5..a137720 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java @@ -13,6 +13,7 @@ import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.issue.repository.TaskRepository; import dynamicquad.agilehub.issue.service.command.ImageService; +import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.AssigneeDto; import dynamicquad.agilehub.member.service.MemberService; @@ -34,6 +35,7 @@ public class StoryFactory implements IssueFactory { private final IssueRepository issueRepository; private final TaskRepository taskRepository; private final ImageService imageService; + private final IssueNumberGenerator issueNumberGenerator; private final MemberService memberService; @@ -43,9 +45,9 @@ public class StoryFactory implements IssueFactory { @Transactional @Override public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ] - // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ] - int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1); + + // 이슈 번호 생성 + String issueNumber = issueNumberGenerator.generate(project.getKey()); Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); Epic upEpic = retrieveEpicFromParentIssue(request.getParentId()); @@ -138,7 +140,7 @@ private void validateParentIssue(Long parentId) { } } - private Story toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee, + private Story toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee, Epic upEpic) { return Story.builder() .title(request.getTitle()) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java index 994daaf..d28a830 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java @@ -10,6 +10,7 @@ import dynamicquad.agilehub.issue.dto.IssueResponseDto; import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.issue.service.command.ImageService; +import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.AssigneeDto; import dynamicquad.agilehub.member.service.MemberService; @@ -30,6 +31,7 @@ public class TaskFactory implements IssueFactory { private final IssueRepository issueRepository; private final ImageService imageService; private final MemberService memberService; + private final IssueNumberGenerator issueNumberGenerator; @Value("${aws.s3.workingDirectory.issue}") private String WORKING_DIRECTORY; @@ -37,9 +39,8 @@ public class TaskFactory implements IssueFactory { @Transactional @Override public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - // TODO: 이슈가 삭제되면 이슈 번호가 중복될 수 있음 1번,2번,3번 이슈 생성뒤 2번 삭제하면 4번 이슈 생성시 3번이 되어 중복 [ ] - // TODO: 이슈 번호 생성 로직을 따로 만들기 - number 최대로 큰 숫자 + 1로 로직 변경 [ ] - int issueNumber = (int) (issueRepository.countByProjectKey(project.getKey()) + 1); + + String issueNumber = issueNumberGenerator.generate(project.getKey()); Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); Story upStory = retrieveStoryFromParentIssue(request.getParentId()); @@ -117,7 +118,7 @@ private void validateParentIssue(Long parentId) { } - private Task toEntity(IssueRequestDto.CreateIssue request, Project project, int issueNumber, Member assignee, + private Task toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee, Story upStory) { return Task.builder() .title(request.getTitle()) diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java index 56af4fe..82c231b 100644 --- a/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java +++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectService.java @@ -2,6 +2,8 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; +import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import dynamicquad.agilehub.project.controller.request.ProjectRequest.ProjectCreateRequest; import dynamicquad.agilehub.project.controller.request.ProjectRequest.ProjectUpdateRequest; @@ -23,6 +25,8 @@ public class ProjectService { private final ProjectRepository projectRepository; private final MemberProjectService memberProjectService; + private final ProjectIssueSequenceRepository issueSequenceRepository; + @Transactional public String createProject(ProjectCreateRequest request, AuthMember authMember) { validateKeyUniqueness(request.getKey()); @@ -30,6 +34,9 @@ public String createProject(ProjectCreateRequest request, AuthMember authMember) // 프로젝트 생성자는 프로젝트에 대한 모든 권한을 가짐(ADMIN) memberProjectService.createMemberProject(authMember, project, MemberProjectRole.ADMIN); + // 이슈 시퀀스 생성 + issueSequenceRepository.save(new ProjectIssueSequence(project.getKey())); + return project.getKey(); } diff --git a/src/main/resources/shema.sql b/src/main/resources/shema.sql index 1e5509e..1a41303 100644 --- a/src/main/resources/shema.sql +++ b/src/main/resources/shema.sql @@ -23,7 +23,7 @@ create table image ( primary key (image_id) ) engine=InnoDB; create table issue ( - number integer not null, + number varchar(255), issue_id bigint not null auto_increment, member_id bigint, project_id bigint, @@ -36,6 +36,15 @@ create table issue ( label enum ('NONE', 'PLAN', 'DESIGN', 'DEVELOP', 'TEST', 'FEEDBACK'), primary key (issue_id) ) engine=InnoDB; + +CREATE TABLE project_issue_sequence ( + project_key VARCHAR(255) PRIMARY KEY, + last_number INT NOT NULL DEFAULT 0, + version BIGINT DEFAULT 0, + created_at timestamp(6) not null, + updated_at timestamp(6) not null +) ENGINE=InnoDB; + create table member ( created_at timestamp(6) not null, member_id bigint not null auto_increment, diff --git a/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java new file mode 100644 index 0000000..866a858 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/IssueConcurrencyTest.java @@ -0,0 +1,164 @@ +package dynamicquad.agilehub.issue; + + +import static java.util.stream.Collectors.toSet; +import static org.assertj.core.api.Assertions.assertThat; + +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; +import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; +import dynamicquad.agilehub.issue.service.command.IssueService; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.member.repository.MemberRepository; +import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRepository; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.project.domain.ProjectRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +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.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class IssueConcurrencyTest { + + @Autowired + private IssueService issueService; + + @Autowired + private ProjectRepository projectRepository; + + @Autowired + private ProjectIssueSequenceRepository projectIssueSequenceRepository; + + @Autowired + private IssueRepository issueRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + MemberProjectRepository memberProjectRepository; + + Project testProject; + ArrayList authMembers; + + @BeforeEach + void init() { + testProject = createTestProject(); + projectRepository.save(testProject); + projectIssueSequenceRepository.save(new ProjectIssueSequence(testProject.getKey())); + authMembers = new ArrayList<>(); + } + + @Test + @DisplayName("동시에 10명의 사용자가 이슈를 생성할 때") + void concurrentCreateTest() throws InterruptedException { + //given + + int numberOfUsers = 10; + createMembers(numberOfUsers); + + CountDownLatch latch = new CountDownLatch(numberOfUsers); + List> futures = new ArrayList<>(); + + // when + + for (int i = 0; i < numberOfUsers; i++) { + Integer finalI = i; + CompletableFuture future = CompletableFuture.supplyAsync(() -> { + try { + IssueRequestDto.CreateIssue request = createIssueRequest(); + return issueService.createIssue(testProject.getKey(), request, authMembers.get(finalI)); + } finally { + latch.countDown(); + } + }).exceptionally(e -> { + Throwable cause = e.getCause(); + System.out.println("발생한 예외: " + cause.getClass().getName()); + System.out.println("예외 메시지: " + cause.getMessage()); + System.out.println("USER 에러 발생"); + return null; + }); + futures.add(future); + } + + // then + latch.await(10, TimeUnit.SECONDS); // 최대 10초 대기 + + // 모든 Future 완료 대기 + List issueIds = futures.stream() + .map(CompletableFuture::join) + .toList(); + + // 생성된 이슈 검증 + List issues = issueRepository.findAllById(issueIds); + + // 이슈 번호 중복 검사 + Set issueNumbers = issues.stream() + .map(Issue::getNumber) + .collect(toSet()); + + assertThat(issueNumbers).hasSize(numberOfUsers); + } + + private IssueRequestDto.CreateIssue createIssueRequest() { + return IssueRequestDto.CreateIssue.builder() + .title("이슈 제목") + .content("이슈 내용") + .type(IssueType.EPIC) + .build(); + } + + + private void createMembers(int memberCount) { + for (int j = 0; j < memberCount; j++) { + Member member = Member.builder() + .name("사용자" + j) + .build(); + memberRepository.save(member); + memberProjectRepository.save(MemberProject.builder() + .member(member) + .project(testProject) + .role(MemberProjectRole.ADMIN) + .build()); + authMembers.add(AuthMember.builder() + .id(member.getId()) + .name(member.getName()) + .build()); + } + } + + private Project createTestProject() { + return Project.builder() + .name("테스트 프로젝트") + .key("TEST") + .build(); + } + + @AfterEach + void cleanUp() { + issueRepository.deleteAll(); + memberProjectRepository.deleteAll(); + projectRepository.deleteAll(); + memberRepository.deleteAll(); + projectIssueSequenceRepository.deleteAll(); + + + } + +} diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java index eb7dabb..d8d9eb3 100644 --- a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java @@ -75,7 +75,7 @@ void setUp() { Epic epic = Epic.builder() .title("에픽1") .content("에픽1 내용") - .number(1) + .number("") .status(IssueStatus.DO) .startDate(LocalDate.of(2024, 1, 1)) .endDate(LocalDate.of(2024, 1, 10)) diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java index b2f4aeb..1b22231 100644 --- a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java @@ -24,49 +24,6 @@ class IssueRepositoryTest { @Autowired private IssueRepository issueRepository; - @Test - @Transactional - void 특정_프로젝트키를_가진_이슈들의_총_개수를_구한다() { - // given - Project project1 = createProject("프로젝트1", "project1"); - em.persist(project1); - - Project project2 = createProject("프로젝트2", "project2"); - em.persist(project2); - - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); - - Story story1P1 = createStory("스토리1", "스토리1 내용", project1); - em.persist(story1P1); - - Story storyP2 = createStory("스토리2", "스토리2 내용", project2); - em.persist(storyP2); - - Task taskP2 = createTask("태스크1", "태스크1 내용", project2); - em.persist(taskP2); - - // when - // then - assertThat(issueRepository.countByProjectKey("project1")).isEqualTo(3); - assertThat(issueRepository.countByProjectKey("project2")).isEqualTo(2); - - } - - - @Test - @Transactional - void 특정_프로젝트키를_가진_이슈가_없을때_0을_반환한다() { - // given - Project project1 = createProject("프로젝트1", "project1141"); - em.persist(project1); - // when - // then - assertThat(issueRepository.countByProjectKey("project1")).isEqualTo(0); - } @Test @Transactional diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java index 3d59251..c18c442 100644 --- a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java @@ -74,7 +74,7 @@ void setUp() { Epic epic = Epic.builder() .title("에픽1") .content("에픽1 내용") - .number(1) + .number("") .status(IssueStatus.DO) .assignee(member1) .project(project) @@ -87,7 +87,7 @@ void setUp() { Story story = Story.builder() .title("스토리1") .content("스토리1 내용") - .number(1) + .number("") .status(IssueStatus.PROGRESS) .assignee(member1) .project(project) diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java index ae14733..4f0a92c 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java @@ -30,13 +30,13 @@ class IssueServiceTest { @PersistenceContext EntityManager em; - @Transactional @Test void 에픽이_지워지면_스토리_테스크_모두_지워진다() { // given Project project1 = createProject("프로젝트1", "project12311"); em.persist(project1); + Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); em.persist(epic1P1); Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java index a4ede46..b6fb4c5 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java @@ -131,7 +131,7 @@ private Issue createTestIssue() {

이슈 내용입니다.

""" ) - .number(1) + .number("") .status(IssueStatus.DO) .assignee(null) .label(IssueLabel.TEST) diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java index 6928351..6cf7fff 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java @@ -6,9 +6,9 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Image; +import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.issue.dto.IssueRequestDto; import dynamicquad.agilehub.issue.dto.IssueResponseDto; import dynamicquad.agilehub.issue.dto.IssueResponseDto.ContentDto; @@ -83,7 +83,6 @@ class EpicFactoryTest { //then assertThat(epic.getTitle()).isEqualTo("이슈 제목"); assertThat(epic.getContent()).isEqualTo("content 내용"); - assertThat(epic.getNumber()).isEqualTo(1); assertThat(epic.getStatus()).isEqualTo(IssueStatus.DO); assertThat(epic.getStartDate()).isEqualTo(LocalDate.of(2024, 2, 19)); assertThat(epic.getEndDate()).isEqualTo(LocalDate.of(2024, 2, 23)); @@ -168,7 +167,7 @@ class EpicFactoryTest { Epic epic = Epic.builder() .title("이슈 제목") .content("content 내용") - .number(1) + .number("") .status(IssueStatus.DO) .assignee(null) .project(project1) @@ -207,7 +206,7 @@ class EpicFactoryTest { Epic epic = Epic.builder() .title("이슈 제목") .content("content 내용") - .number(1) + .number("") .status(IssueStatus.DO) .assignee(null) .project(project1) @@ -255,7 +254,7 @@ class EpicFactoryTest { Epic epic = Epic.builder() .title("이슈 제목") .content("content 내용") - .number(1) + .number("") .status(IssueStatus.DO) .assignee(member1) .project(project1) diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java index 66b4d37..e5f74fd 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java @@ -6,11 +6,13 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; import dynamicquad.agilehub.issue.domain.Story; import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import dynamicquad.agilehub.project.domain.Project; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -30,6 +32,9 @@ class TaskFactoryTest { @Autowired private TaskFactory taskFactory; + @Autowired + private ProjectIssueSequenceRepository projectIssueSequenceRepository; + @Test @Transactional void 부모이슈가_에픽이거나_테스크일때_예외처리() { @@ -37,6 +42,8 @@ class TaskFactoryTest { Project project = createProject("프로젝트1", "project124151"); em.persist(project); + projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey())); + Epic epic = Epic.builder() .title("에픽제목") .status(IssueStatus.DONE) @@ -85,6 +92,7 @@ class TaskFactoryTest { // given Project project = createProject("프로젝트1", "proje123ct1"); em.persist(project); + projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey())); Story story = Story.builder() .title("story제목") diff --git a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java index 4819542..600bd64 100644 --- a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java @@ -79,7 +79,7 @@ private Epic createEpic(String title, String content, Project project) { return Epic.builder() .title(title) .content(content) - .number(1) + .number("") .project(project) .build(); } @@ -89,7 +89,7 @@ private Story createStory(String title, String content, Project project) { .title(title) .content(content) .project(project) - .number(2) + .number("") .build(); } @@ -98,7 +98,7 @@ private Task createTask(String title, String content, Project project) { .title(title) .content(content) .project(project) - .number(3) + .number("") .build(); } From 04ec1c6748fb9222e452a242edc23a9b03a374fb Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Sat, 9 Nov 2024 16:31:02 +0900 Subject: [PATCH 07/45] =?UTF-8?q?refactor:=20hikari=20pool=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/issue/service/command/ImageService.java | 2 +- .../issue/service/command/IssueNumberGenerator.java | 4 +--- .../agilehub/issue/service/command/IssueService.java | 2 +- .../agilehub/issue/service/factory/EpicFactory.java | 2 -- .../agilehub/member/service/MemberService.java | 1 + src/main/resources/application-prod.yml | 10 ++++++++++ src/main/resources/application.yml | 4 ++++ 7 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java index f8d5b28..1882c0c 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/ImageService.java @@ -17,7 +17,7 @@ public class ImageService { private final ImageRepository imageRepository; private final PhotoS3Manager photoS3Manager; - + public void saveImages(Issue issue, List files, String workingDirectory) { List imagePath = photoS3Manager.uploadPhotos(files, workingDirectory); if (imagePath.isEmpty()) { diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index ce2b80e..1afc9fb 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -4,7 +4,6 @@ import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -13,8 +12,7 @@ public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) - //@Retry + @Transactional public String generate(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java index d085949..8dd7529 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java @@ -35,7 +35,7 @@ public class IssueService { private final IssueRepository issueRepository; - @Transactional + public Long createIssue(String key, IssueRequestDto.CreateIssue request, AuthMember authMember) { Project project = validateMemberInProject(key, authMember); diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java index 0d5d5e2..d176c8e 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java @@ -43,8 +43,6 @@ public class EpicFactory implements IssueFactory { public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { // 이슈 번호 생성 - // 리트라이 3번 - String issueNumber = issueNumberGenerator.generate(project.getKey()); // 멤버를 찾기 diff --git a/src/main/java/dynamicquad/agilehub/member/service/MemberService.java b/src/main/java/dynamicquad/agilehub/member/service/MemberService.java index e9fdebb..c75f849 100644 --- a/src/main/java/dynamicquad/agilehub/member/service/MemberService.java +++ b/src/main/java/dynamicquad/agilehub/member/service/MemberService.java @@ -21,6 +21,7 @@ public Member save(Member member) { return memberRepository.save(member); } + @Transactional(readOnly = true) public Member findMember(Long assigneeId, Long projectId) { if (assigneeId == null) { return null; diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 012d6b8..019efbd 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -9,6 +9,16 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + # 풀 사이즈 설정 + maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10 + minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게 + + # 시간 관련 설정 + max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 + connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API + validation-timeout: 1000 # 1초, connection-timeout보다 작게 + sql: init: mode: never diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 97c7b2a..86407e5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -18,6 +18,10 @@ springdoc: aws: + credentials: + accessKey: ${AWS_ACCESS_KEY} + secretKey: ${AWS_SECRET_KEY} + region: ap-northeast-2 s3: rootURL: https://image.agilehub.store bucket: agilehub From 06333f53f0325415dfa658f883abd15345774174 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sun, 17 Nov 2024 20:55:21 +0900 Subject: [PATCH 08/45] =?UTF-8?q?refactor:=20hikari=20pool=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A7=80=EC=9B=80=20(=EB=AA=A8=EB=8B=88=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EB=95=8C=EB=AC=B8=EC=97=90)=20(#133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 019efbd..012d6b8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -9,16 +9,6 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - # 풀 사이즈 설정 - maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10 - minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게 - - # 시간 관련 설정 - max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 - connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API - validation-timeout: 1000 # 1초, connection-timeout보다 작게 - sql: init: mode: never From 99ff4b5dcc561f96920c7c72756cba9d747352ad Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sun, 17 Nov 2024 21:07:03 +0900 Subject: [PATCH 09/45] Refactor/monitor deadlock (#134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: hikari pool 설정 지움 (모니터링 때문에) * refactor: 트랜잭션 2개로 복귀 (모니터링 때문에) --- .../agilehub/issue/service/command/IssueNumberGenerator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index 1afc9fb..a30e3c2 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -4,6 +4,7 @@ import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -12,7 +13,7 @@ public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public String generate(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); From 6a5fa0e3badac7cb5014c09b998119b63975ecac Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sun, 17 Nov 2024 21:42:24 +0900 Subject: [PATCH 10/45] Refactor/monitor deadlock (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: hikari pool 설정 지움 (모니터링 때문에) * refactor: 트랜잭션 2개로 복귀 (모니터링 때문에) * refactor: hikaripool 로그 --- src/main/resources/application-prod.yml | 10 +++++++++- src/main/resources/logback-spring.xml | 5 +++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 012d6b8..796d6e9 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -30,4 +30,12 @@ management: prometheus: enabled: true logfile: - external-file: ./logs/agilehub-prod.log \ No newline at end of file + external-file: ./logs/agilehub-prod.log + +logging: + level: + com.zaxxer.hikari.HikariConfig: debug + com.zaxxer.hikari: TRACE + org.springframework.transaction: TRACE + file: + name: hikari-pool.log \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 5162435..ad98b16 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -37,6 +37,11 @@ + + + + + From 112050eed00c9c7ad25b9c647ebb7b173c8e036e Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sun, 17 Nov 2024 21:52:18 +0900 Subject: [PATCH 11/45] Refactor/monitor deadlock (#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: hikari pool 설정 지움 (모니터링 때문에) * refactor: 트랜잭션 2개로 복귀 (모니터링 때문에) * refactor: hikaripool 로그 * refactor: hikaripool 로그 From 0be9b79fd2d5a8bfed0edc9087a852cf399b3dc5 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Sun, 17 Nov 2024 23:59:38 +0900 Subject: [PATCH 12/45] =?UTF-8?q?refactor:=20hikaripool=20=ED=92=80=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20(20->50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 019efbd..96d443f 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -11,7 +11,7 @@ spring: hikari: # 풀 사이즈 설정 - maximum-pool-size: 20 # VUser 20 기준, 톰캣 스레드(200)의 1/10 + maximum-pool-size: 50 # VUser 20 기준, 톰캣 스레드(200)의 1/10 minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게 # 시간 관련 설정 From 6547538942bb37091ebecd6162568c8b20698b8e Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Mon, 18 Nov 2024 00:12:19 +0900 Subject: [PATCH 13/45] =?UTF-8?q?refactor:=20=ED=9E=88=EC=B9=B4=EB=A6=AC?= =?UTF-8?q?=ED=92=80=20=EA=B4=80=EB=A0=A8=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/issue/service/command/IssueNumberGenerator.java | 3 +-- src/main/resources/logback-spring.xml | 5 ----- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index a30e3c2..1afc9fb 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -4,7 +4,6 @@ import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -13,7 +12,7 @@ public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public String generate(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index ad98b16..5162435 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -37,11 +37,6 @@ - - - - - From 4b73aeb47fc8c1889b675a836e4bb9bd0faa0949 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 19 Nov 2024 01:04:03 +0900 Subject: [PATCH 14/45] =?UTF-8?q?refactor:=20tomcat=20thread=2050=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EA=B3=A0=20hikari=20pool=2075=20(#137)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../issue/service/command/IssueNumberGenerator.java | 3 ++- src/main/resources/application-local.yml | 4 ++++ src/main/resources/application-prod.yml | 11 ----------- src/main/resources/application.yml | 13 +++++++++++++ 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index 1afc9fb..a30e3c2 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -4,6 +4,7 @@ import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -12,7 +13,7 @@ public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public String generate(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index aacc7a2..233f222 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -27,3 +27,7 @@ logging: org.hibernate.SQL: debug root: info org.hibernate.orm.jdbc.bind: trace + com.zaxxer.hikari: debug + com.zaxxer.hikari.HikariConfig: debug + com.zaxxer.hikari.HikariPool: debug + com.zaxxer.hikari.pool.PoolBase: DEBUG diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 09f069d..012d6b8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -9,17 +9,6 @@ spring: password: ${MYSQL_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver - - hikari: - # 풀 사이즈 설정 - maximum-pool-size: 50 # VUser 20 기준, 톰캣 스레드(200)의 1/10 - minimum-idle: 20 # 응답속도가 중요한 시스템이므로 maximum과 동일하게 - - # 시간 관련 설정 - max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 - connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API - validation-timeout: 1000 # 1초, connection-timeout보다 작게 - sql: init: mode: never diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 86407e5..163e9a9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,6 +8,19 @@ spring: max-request-size: 100MB file-size-threshold: 0B enabled: true + server: + tomcat: + max-threads: 50 + min-spare-threads: 25 + hikari: + # 풀 사이즈 설정 + maximum-pool-size: 75 + minimum-idle: 75 + + # 시간 관련 설정 + max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 + connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API + validation-timeout: 1000 # 1초, connection-timeout보다 작게 springdoc: swagger-ui: From 699fad2950cb5cccf10e90c9f305077bffa179ff Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 19 Nov 2024 08:13:24 +0900 Subject: [PATCH 15/45] Refactor/tomcat and hikaripool (#138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: tomcat thread 50 그리고 hikari pool 75 * refactor: tomcat thread 최소개수도 50 --- src/main/resources/application.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 163e9a9..89217dc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,7 +11,8 @@ spring: server: tomcat: max-threads: 50 - min-spare-threads: 25 + min-spare-threads: 50 + hikari: # 풀 사이즈 설정 maximum-pool-size: 75 From 3083bd7a71faf46bba500d8be8c966dbe086c3b3 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 19 Nov 2024 08:21:54 +0900 Subject: [PATCH 16/45] Refactor/tomcat and hikaripool (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: tomcat thread 50 그리고 hikari pool 75 * refactor: tomcat thread 최소개수도 50 * refactor: tomcat thread 최소개수도 100 * refactor: tomcat thread 최소개수도 100 --- src/main/resources/application.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 89217dc..a1ac75f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,9 +10,9 @@ spring: enabled: true server: tomcat: - max-threads: 50 - min-spare-threads: 50 - + max-threads: 100 + min-spare-threads: 100 + hikari: # 풀 사이즈 설정 maximum-pool-size: 75 From 7795fe023d3e5be6bdc01baf6ee22cf7376bda6b Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 19 Nov 2024 08:33:35 +0900 Subject: [PATCH 17/45] Refactor/tomcat and hikaripool (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: tomcat thread 50 그리고 hikari pool 75 * refactor: tomcat thread 최소개수도 50 * refactor: tomcat thread 최소개수도 100 * refactor: tomcat thread 최소개수도 100 * refactor: tomcat thread 200 --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a1ac75f..8b236b6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,8 +10,8 @@ spring: enabled: true server: tomcat: - max-threads: 100 - min-spare-threads: 100 + max-threads: 200 + min-spare-threads: 200 hikari: # 풀 사이즈 설정 From e700f43ac31daaf64579336e29044e09ae883bab Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 19 Nov 2024 08:42:47 +0900 Subject: [PATCH 18/45] Refactor/tomcat and hikaripool (#141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: tomcat thread 50 그리고 hikari pool 75 * refactor: tomcat thread 최소개수도 50 * refactor: tomcat thread 최소개수도 100 * refactor: tomcat thread 최소개수도 100 * refactor: tomcat thread 200 * refactor: tomcat thread --- src/main/resources/application.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b236b6..fb6afa8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,8 +10,8 @@ spring: enabled: true server: tomcat: - max-threads: 200 - min-spare-threads: 200 + max-threads: 75 + min-spare-threads: 75 hikari: # 풀 사이즈 설정 @@ -20,7 +20,7 @@ spring: # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 - connection-timeout: 3000 # 3초, 빠른 응답이 필요한 API + connection-timeout: 5000 # 3초, 빠른 응답이 필요한 API validation-timeout: 1000 # 1초, connection-timeout보다 작게 springdoc: From 8fb0fe62989272e97ebd072e7521b320afb3d12f Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 08:47:58 +0900 Subject: [PATCH 19/45] refactor: tomcat thread remove --- src/main/resources/application.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb6afa8..07d5b95 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,10 +8,6 @@ spring: max-request-size: 100MB file-size-threshold: 0B enabled: true - server: - tomcat: - max-threads: 75 - min-spare-threads: 75 hikari: # 풀 사이즈 설정 @@ -20,7 +16,7 @@ spring: # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 - connection-timeout: 5000 # 3초, 빠른 응답이 필요한 API + connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API validation-timeout: 1000 # 1초, connection-timeout보다 작게 springdoc: From ea0bd98d7b8e4d3bfdabde20cb35aba05254b0e0 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 08:51:26 +0900 Subject: [PATCH 20/45] =?UTF-8?q?refactor:=20=EC=A0=84=ED=8C=8C=20?= =?UTF-8?q?=EC=97=86=EC=95=A0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/issue/service/command/IssueNumberGenerator.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index a30e3c2..1afc9fb 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -4,7 +4,6 @@ import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -13,7 +12,7 @@ public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public String generate(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); From 65e0a6424a1ceaae45f5df2f3c93d1a134d3e2fb Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 21:09:28 +0900 Subject: [PATCH 21/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?= =?UTF-8?q?i=202=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 32 +++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 07d5b95..b1592f7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,15 +9,29 @@ spring: file-size-threshold: 0B enabled: true - hikari: - # 풀 사이즈 설정 - maximum-pool-size: 75 - minimum-idle: 75 - - # 시간 관련 설정 - max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 - connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API - validation-timeout: 1000 # 1초, connection-timeout보다 작게 + datasource: + hikari: + # 풀 사이즈 설정 + maximum-pool-size: 2 + minimum-idle: 2 + + # 시간 관련 설정 + max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 + connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API + validation-timeout: 1000 # 1초, connection-timeout보다 작게 + +# 톰켓 설정 +server: + tomcat: + accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 + max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 + threads: + max: 10 # 최대 스레드 수 + min-spare: 10 # 초기 스레드 풀 사이즈 + + + + springdoc: swagger-ui: From 59cc140a1015f63a9ed4e4eefd2e5d5aab24e081 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 21:38:39 +0900 Subject: [PATCH 22/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?= =?UTF-8?q?i=205=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b1592f7..781a895 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: datasource: hikari: # 풀 사이즈 설정 - maximum-pool-size: 2 - minimum-idle: 2 + maximum-pool-size: 5 + minimum-idle: 5 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 From 24ae197db232e319cf0c97e4d2541a717274cfd7 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 21:44:48 +0900 Subject: [PATCH 23/45] =?UTF-8?q?refactor:=20thread=2010=EA=B0=9C,=20hikar?= =?UTF-8?q?i=208=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 781a895..bf9bcc0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: datasource: hikari: # 풀 사이즈 설정 - maximum-pool-size: 5 - minimum-idle: 5 + maximum-pool-size: 8 + minimum-idle: 8 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 From d23805c1cbb75091a00f9f6a762b90252e7c80fb Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 19 Nov 2024 21:51:35 +0900 Subject: [PATCH 24/45] =?UTF-8?q?refactor:=20thread=2016=EA=B0=9C,=20hikar?= =?UTF-8?q?i=208=EA=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf9bcc0..75f4ef7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,8 +26,8 @@ server: accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 threads: - max: 10 # 최대 스레드 수 - min-spare: 10 # 초기 스레드 풀 사이즈 + max: 16 # 최대 스레드 수 + min-spare: 16 # 초기 스레드 풀 사이즈 From a4d8f2efd5d1f7027648e03f07515e0b7a6044af Mon Sep 17 00:00:00 2001 From: Min Sang Date: Wed, 20 Nov 2024 01:09:38 +0900 Subject: [PATCH 25/45] =?UTF-8?q?feat:=20redis=20issuenumber=20=EC=A6=9D?= =?UTF-8?q?=EA=B0=80=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/global/config/RedisConfig.java | 25 ++++----- .../service/command/IssueNumberGenerator.java | 56 +++++++++++++++++-- src/main/resources/application-redis.yml | 5 ++ src/main/resources/application.yml | 4 +- 4 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java index 9a99b93..dd9f253 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java @@ -7,11 +7,10 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration -@EnableRedisRepositories +//@EnableRedisRepositories public class RedisConfig { @Value("${spring.data.redis.host}") @@ -21,20 +20,18 @@ public class RedisConfig { private int port; @Bean - public RedisConnectionFactory redisConnectionFactory() { - RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration(); - redisStandaloneConfiguration.setHostName(host); - redisStandaloneConfiguration.setPort(port); -// redisStandaloneConfiguration.setPassword("password"); - - return new LettuceConnectionFactory(redisStandaloneConfiguration); + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + template.setConnectionFactory(redisConnectionFactory()); + return template; } @Bean - public RedisTemplate redisTemplate() { - StringRedisTemplate redisTemplate = new StringRedisTemplate(); - redisTemplate.setConnectionFactory(redisConnectionFactory()); - return redisTemplate; + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + return new LettuceConnectionFactory(config); } } diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index 1afc9fb..6b5dcf4 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -2,26 +2,56 @@ import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor +@Slf4j public class IssueNumberGenerator { private final ProjectIssueSequenceRepository issueSequenceRepository; + private final RedisTemplate redisTemplate; - @Transactional - public String generate(String projectKey) { - ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) - .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); + @Value("${redis.issue.number.prefix}") + private String REDIS_ISSUE_PREFIX; + +// @Transactional +// public String generate(String projectKey) { +// ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) +// .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); +// +// sequence.updateLastNumber(sequence.getNextNumber()); +// +// return projectKey + "-" + sequence.getLastNumber(); +// } - sequence.updateLastNumber(sequence.getNextNumber()); + public String generate(String projectKey) { + String redisKey = REDIS_ISSUE_PREFIX + projectKey; + Long nextNumber = redisTemplate.opsForValue().increment(redisKey); + return projectKey + "-" + nextNumber; + } - return projectKey + "-" + sequence.getLastNumber(); + @Scheduled(fixedDelay = 1000L * 18L) + @Transactional + public void syncWithDatabase() { + log.info("찍히나?"); + issueSequenceRepository.findAll().forEach(sequence -> { + String redisKey = REDIS_ISSUE_PREFIX + sequence.getProjectKey(); + String currentValue = redisTemplate.opsForValue().get(redisKey); + if (currentValue != null) { + sequence.updateLastNumber(Integer.parseInt(currentValue)); + } + }); } + @Transactional public void decrement(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) @@ -29,4 +59,18 @@ public void decrement(String projectKey) { sequence.decrement(); } + + @PostConstruct + public void initializeRedis() { + issueSequenceRepository.findAll().forEach(seq -> { + String redisKey = REDIS_ISSUE_PREFIX + seq.getProjectKey(); + redisTemplate.opsForValue().set(redisKey, String.valueOf(seq.getLastNumber())); + }); + } + + // 내부 트랜잭션 호출이라 안됨 +// @PreDestroy +// public void saveToDatabase() { +// syncWithDatabase(); // 종료 전 마지막 동기화 +// } } diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml index 36ca51a..ac62e83 100644 --- a/src/main/resources/application-redis.yml +++ b/src/main/resources/application-redis.yml @@ -4,3 +4,8 @@ spring: host: ${REDIS_HOST} port: 6379 # password: + +redis: + issue: + number: + prefix: ISSUE \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 75f4ef7..bf9bcc0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,8 +26,8 @@ server: accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 threads: - max: 16 # 최대 스레드 수 - min-spare: 16 # 초기 스레드 풀 사이즈 + max: 10 # 최대 스레드 수 + min-spare: 10 # 초기 스레드 풀 사이즈 From d3b4b0cf356e96fe09c92d4519961eb9ed246d26 Mon Sep 17 00:00:00 2001 From: Min Sang Date: Wed, 20 Nov 2024 18:04:38 +0900 Subject: [PATCH 26/45] Refactor/redis issue generate (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: redis issuenumber 증가 * feat: redis write-back 전략을 위해 스케줄러 추가 --- .../global/config/SchedulingConfig.java | 29 +++++++++++++++++++ .../service/command/IssueNumberGenerator.java | 26 ++++++++++------- src/main/resources/application-redis.yml | 3 +- 3 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java diff --git a/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java b/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java new file mode 100644 index 0000000..22e15fd --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/config/SchedulingConfig.java @@ -0,0 +1,29 @@ +package dynamicquad.agilehub.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +@Configuration +@EnableScheduling +@Slf4j +public class SchedulingConfig implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(10); // 풀 크기 설정 + scheduler.setThreadNamePrefix("my-scheduled-task-"); + scheduler.setAwaitTerminationSeconds(60); + scheduler.setWaitForTasksToCompleteOnShutdown(true); // 종료 시 실행 중인 작업 완료 대기 + scheduler.setErrorHandler(throwable -> + log.error("Scheduled task error", throwable)); // 에러 핸들링 + + scheduler.initialize(); + taskRegistrar.setTaskScheduler(scheduler); + } + +} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index 6b5dcf4..8f6acfa 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -38,21 +38,32 @@ public String generate(String projectKey) { return projectKey + "-" + nextNumber; } - @Scheduled(fixedDelay = 1000L * 18L) + @Scheduled(fixedDelay = 1000L * 30L) @Transactional public void syncWithDatabase() { - log.info("찍히나?"); issueSequenceRepository.findAll().forEach(sequence -> { String redisKey = REDIS_ISSUE_PREFIX + sequence.getProjectKey(); String currentValue = redisTemplate.opsForValue().get(redisKey); + if (currentValue != null) { - sequence.updateLastNumber(Integer.parseInt(currentValue)); + try { + int redisValue = Integer.parseInt(currentValue); + + // 현재 DB 값보다 큰 경우에만 업데이트 + if (redisValue > sequence.getLastNumber()) { + sequence.updateLastNumber(redisValue); + log.debug("Synced sequence for project {}: {}", + sequence.getProjectKey(), redisValue); + } + + } catch (NumberFormatException e) { + log.error("Invalid number format in Redis for key: {}", redisKey, e); + redisTemplate.delete(redisKey); + } } }); } - - @Transactional public void decrement(String projectKey) { ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); @@ -68,9 +79,4 @@ public void initializeRedis() { }); } - // 내부 트랜잭션 호출이라 안됨 -// @PreDestroy -// public void saveToDatabase() { -// syncWithDatabase(); // 종료 전 마지막 동기화 -// } } diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml index ac62e83..c319c3b 100644 --- a/src/main/resources/application-redis.yml +++ b/src/main/resources/application-redis.yml @@ -8,4 +8,5 @@ spring: redis: issue: number: - prefix: ISSUE \ No newline at end of file + prefix: 'ISSUE::' + From 1087f94d51137bb0b5f061773a2d774c7915ac8c Mon Sep 17 00:00:00 2001 From: Min Sang Date: Sat, 7 Dec 2024 17:35:13 +0900 Subject: [PATCH 27/45] =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=B4=88=EB=8C=80?= =?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#145)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: redis replication 구성 * refactor: 이메일 비동기 처리 서비스로 변경 --- docker-compose.yml | 31 ++++++++++-- .../agilehub/global/config/AsyncConfig.java | 29 +++++++++++ .../agilehub/global/config/RedisConfig.java | 5 ++ .../global/mail/service/EmailService.java | 50 +++++++++++++------ .../agilehub/issue/aspect/Retry.java | 5 ++ .../agilehub/issue/aspect/RetryAspect.java | 43 ++++++++++------ .../project/service/ProjectInviteService.java | 14 ++++-- src/main/resources/application-redis.yml | 1 + 8 files changed, 142 insertions(+), 36 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java diff --git a/docker-compose.yml b/docker-compose.yml index a89b647..abe88a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -# 로컬 DB 구성 version: '3' services: @@ -21,18 +20,42 @@ services: - app-network restart: always - redis: + redis-master: image: redis:7.0 - container_name: redis + container_name: redis-master ports: - "0.0.0.0:6379:6379" volumes: - ./redis/data:/data - command: redis-server --appendonly yes + command: redis-server --requirepass ready!action --appendonly yes networks: - app-network restart: always + redis-slave1: + image: redis:7.0 + container_name: redis-slave1 + ports: + - "0.0.0.0:6380:6379" + command: redis-server --requirepass yourstrong!password --masterauth ready!action --replicaof redis-master 6379 --appendonly yes + networks: + - app-network + restart: always + depends_on: + - redis-master + + redis-slave2: + image: redis:7.0 + container_name: redis-slave2 + ports: + - "0.0.0.0:6381:6379" + command: redis-server --requirepass yourstrong!password --masterauth ready!action --replicaof redis-master 6379 --appendonly yes + networks: + - app-network + restart: always + depends_on: + - redis-master + networks: app-network: driver: bridge \ No newline at end of file diff --git a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java new file mode 100644 index 0000000..932352c --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java @@ -0,0 +1,29 @@ +package dynamicquad.agilehub.global.config; + +import java.util.concurrent.Executor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + private static final boolean WAIT_TASK_COMPLETE = true; + private static final int AWAIT_TERMINATION_SECONDS = 30; + + @Bean(name = "emailExecutor") + public Executor emailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(30); + executor.setQueueCapacity(50); + executor.setThreadNamePrefix("email-"); + executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE); + executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + executor.initialize(); + + return executor; + } +} diff --git a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java index dd9f253..1317720 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/RedisConfig.java @@ -19,6 +19,9 @@ public class RedisConfig { @Value("${spring.data.redis.port}") private int port; + @Value("${spring.data.redis.password}") + private String password; + @Bean public RedisTemplate redisTemplate() { RedisTemplate template = new RedisTemplate<>(); @@ -31,6 +34,8 @@ public RedisTemplate redisTemplate() { @Bean public RedisConnectionFactory redisConnectionFactory() { RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + return new LettuceConnectionFactory(config); } diff --git a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java index 4214f70..4f89671 100644 --- a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java +++ b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java @@ -3,13 +3,19 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.global.mail.model.EmailInfo; +import dynamicquad.agilehub.issue.aspect.Retry; import dynamicquad.agilehub.project.model.InviteEmailInfo; import dynamicquad.agilehub.report.SummaryEmailInfo; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; + +import java.util.concurrent.CompletableFuture; + +import org.springframework.mail.MailSendException; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.thymeleaf.context.Context; @@ -22,21 +28,37 @@ public class EmailService { private final JavaMailSender mailSender; private final SpringTemplateEngine templateEngine; - public void sendMail(EmailInfo emailInfo, String type) { - MimeMessage mimeMailMessage = mailSender.createMimeMessage(); - try { - MimeMessageHelper helper = new MimeMessageHelper(mimeMailMessage, false, "UTF-8"); - helper.setTo(emailInfo.getTo()); - helper.setSubject(emailInfo.getSubject()); - if (StringUtils.hasText(type)) { - helper.setText(setContext(emailInfo, type), true); - } else { - helper.setText(emailInfo.getMessage()); + @Async("emailExecutor") + @Retry(maxRetries = 3, // 최대 3번 시도 + retryFor = { MessagingException.class, MailSendException.class }, // 이메일 관련 예외만 재시도 + delay = 1000 // 1초 대기 후 재시도 + ) + public CompletableFuture sendMail(EmailInfo emailInfo, String type) { + return CompletableFuture.runAsync(() -> { + try { + MimeMessage message = createMimeMessage(emailInfo, type); + mailSender.send(message); + } catch (MessagingException me) { + throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); } - mailSender.send(mimeMailMessage); - } catch (MessagingException me) { - throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); - } + }); + } + + private MimeMessage createMimeMessage(EmailInfo emailInfo, String type) throws MessagingException { + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, false, "UTF-8"); + + helper.setTo(emailInfo.getTo()); + helper.setSubject(emailInfo.getSubject()); + helper.setText(getEmailContent(emailInfo, type), true); + + return mimeMessage; + } + + private String getEmailContent(EmailInfo emailInfo, String type) { + return StringUtils.hasText(type) + ? setContext(emailInfo, type) + : emailInfo.getMessage(); } private String setContext(EmailInfo emailInfo, String type) { diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java index 1adbd20..322dcfb 100644 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java @@ -8,4 +8,9 @@ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Retry { + int maxRetries() default 5; + + long delay() default 100; + + Class[] retryFor() default Exception.class; } diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java index 6006ae9..c7d2c1b 100644 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java @@ -1,41 +1,54 @@ package dynamicquad.agilehub.issue.aspect; -import static dynamicquad.agilehub.global.header.status.ErrorStatus.OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER; - -import dynamicquad.agilehub.global.exception.GeneralException; +import java.util.Arrays; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; -import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Component; @Aspect -@Order(Ordered.LOWEST_PRECEDENCE - 1) // 트랜잭션보다 더 낮은 순서로 실행 +@Order(Ordered.HIGHEST_PRECEDENCE) @Component @Slf4j public class RetryAspect { - private static final int MAX_RETRIES = 5; @Around("@annotation(dynamicquad.agilehub.issue.aspect.Retry)") public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { + // Retry 어노테이션 정보 가져오기 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Retry retry = signature.getMethod().getAnnotation(Retry.class); + int attempts = 0; - while (attempts < MAX_RETRIES) { + int maxAttempts = retry.maxRetries(); + + while (attempts < maxAttempts) { try { - log.info("시작 시도: {}", attempts + 1); + log.info("시도 #{}", attempts + 1); return joinPoint.proceed(); - } catch (ObjectOptimisticLockingFailureException e) { + } catch (Exception e) { attempts++; - if (attempts == MAX_RETRIES) { - log.error("Failed after {} attempts", MAX_RETRIES, e); - throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER); + + // 설정된 예외 타입들 중 하나와 일치하는지 확인 + boolean shouldRetry = Arrays.stream(retry.retryFor()) + .anyMatch(exceptionType -> exceptionType.isInstance(e)); + + if (!shouldRetry) { + throw e; // 재시도 대상이 아닌 예외는 즉시 던짐 + } + + if (attempts == maxAttempts) { + log.error("{}회 시도 후 실패", maxAttempts, e); + throw e; } - log.warn("Retry attempt {} of {}", attempts, MAX_RETRIES); - Thread.sleep(100); // 재시도 전 잠시 대기 + + log.warn("재시도 {}/{}", attempts, maxAttempts); + Thread.sleep(retry.delay()); } } - throw new GeneralException(OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER); + throw new RuntimeException("예기치 않은 재시도 실패"); } } diff --git a/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java b/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java index 0dfe143..6a9e7d5 100644 --- a/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java +++ b/src/main/java/dynamicquad/agilehub/project/service/ProjectInviteService.java @@ -9,12 +9,15 @@ import dynamicquad.agilehub.project.domain.Project; import dynamicquad.agilehub.project.model.InviteEmailInfo; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @Transactional(readOnly = true) @RequiredArgsConstructor +@Slf4j public class ProjectInviteService { private static final String INVITE_SUBJECT = "[AgileHub] 초대코드"; @@ -25,7 +28,7 @@ public class ProjectInviteService { private final InviteRedisService inviteRedisService; public void sendInviteEmail(MemberRequestDto.AuthMember authMember, - ProjectInviteRequestDto.SendInviteMail sendInviteMail) { + ProjectInviteRequestDto.SendInviteMail sendInviteMail) { memberProjectService.validateMemberInProject(authMember.getId(), sendInviteMail.getProjectId()); memberProjectService.validateMemberRole(authMember, sendInviteMail.getProjectId()); @@ -40,12 +43,17 @@ public void sendInviteEmail(MemberRequestDto.AuthMember authMember, .inviteCode(inviteCode) .build(); - emailService.sendMail(emailInfo, "invite"); + emailService.sendMail(emailInfo, "invite") + .thenRun(() -> log.info("이메일 전송 완료")) + .exceptionally(e -> { + log.error("이메일 전송 실패", e); + return null; + }); } @Transactional public Project receiveInviteEmail(MemberRequestDto.AuthMember authMember, - String inviteCode) { + String inviteCode) { InviteRedisEntity inviteRedisEntity = inviteRedisService.findByInviteCode(inviteCode); Project project = Project.createPojoProject(Long.parseLong(inviteRedisEntity.getProjectId())); diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml index c319c3b..bbbe1d6 100644 --- a/src/main/resources/application-redis.yml +++ b/src/main/resources/application-redis.yml @@ -3,6 +3,7 @@ spring: redis: host: ${REDIS_HOST} port: 6379 + password: ready!action # password: redis: From 7fab1bf8335c076f6748ce7106f4d398e58b7e2e Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 24 Dec 2024 19:10:09 +0900 Subject: [PATCH 28/45] =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=EC=B4=88=EB=8C=80?= =?UTF-8?q?=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EB=B0=9C=EC=86=A1=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EC=84=A4=EA=B3=84=20=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B3=A0=EB=A0=A4=20=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 멤버초대 컨트롤러, 서비스 틀 구축 * feat: UUID 초대토큰 생성 * feat: ses 서비스 도입 * feat: 이메일 초대 토큰 키-값 레디스에 저장 * feat: 이메일 초대 토큰 키-값 레디스에 저장 * feat: 비동기 적용 및 외부서비스 장애 발생 시 처리 로직 구현 * feat: 타임아웃 설정 * refactor: 이메일 스레드 개수 * refactor: 딜레이 1초는 너무 길기때문에 500ms로 줄임 --- build.gradle | 2 + .../agilehub/email/AmazonSESService.java | 67 +++++++++ .../agilehub/email/InvitationController.java | 28 ++++ .../agilehub/email/InvitationService.java | 12 ++ .../agilehub/email/InvitationServiceImpl.java | 128 ++++++++++++++++++ .../agilehub/email/InvitationStatus.java | 26 ++++ .../agilehub/email/SMTPService.java | 8 ++ .../agilehub/global/config/AsyncConfig.java | 12 +- .../global/config/DefaultSESConfig.java | 33 +++++ .../global/config/TemplateConfig.java | 18 +++ .../global/header/status/ErrorStatus.java | 3 + .../global/util/RandomStringUtil.java | 26 ++++ src/main/resources/application.yml | 4 + src/main/resources/templates/invite.html | 4 +- .../agilehub/email/AmazonSESServiceTest.java | 26 ++++ .../global/util/RandomStringUtilTest.java | 85 ++++++++++++ 16 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/email/AmazonSESService.java create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationController.java create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationService.java create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java create mode 100644 src/main/java/dynamicquad/agilehub/email/InvitationStatus.java create mode 100644 src/main/java/dynamicquad/agilehub/email/SMTPService.java create mode 100644 src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java create mode 100644 src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java create mode 100644 src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java create mode 100644 src/test/java/dynamicquad/agilehub/global/util/RandomStringUtilTest.java diff --git a/build.gradle b/build.gradle index 509b294..41cd867 100644 --- a/build.gradle +++ b/build.gradle @@ -65,6 +65,8 @@ dependencies { // mail implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'com.amazonaws:aws-java-sdk-ses:1.12.408' + // thymeleaf implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java new file mode 100644 index 0000000..d017965 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -0,0 +1,67 @@ +package dynamicquad.agilehub.email; + +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; +import com.amazonaws.services.simpleemail.model.Body; +import com.amazonaws.services.simpleemail.model.Content; +import com.amazonaws.services.simpleemail.model.Destination; +import com.amazonaws.services.simpleemail.model.Message; +import com.amazonaws.services.simpleemail.model.SendEmailRequest; +import dynamicquad.agilehub.global.exception.GeneralException; +import dynamicquad.agilehub.global.header.status.ErrorStatus; +import dynamicquad.agilehub.issue.aspect.Retry; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; +import org.thymeleaf.context.IContext; + +@Service +@RequiredArgsConstructor +public class AmazonSESService implements SMTPService { + + private final AmazonSimpleEmailService amazonSimpleEmailService; + private final TemplateEngine htmlTemplateEngine; + + @Value("${aws.ses.from}") + private String from; + + @Override + @Async("emailExecutor") + @Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) + public CompletableFuture sendEmail(String subject, Map variables, String... to) { + // Amazon SES를 이용한 이메일 발송 + return CompletableFuture.runAsync(() -> { + try { + String content = htmlTemplateEngine.process("invite", createContext(variables)); + SendEmailRequest request = createSendEmailRequest(subject, content, to); + + amazonSimpleEmailService.sendEmail(request); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); + } + }); + + + } + + private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) { + return new SendEmailRequest() + .withDestination(new Destination().withToAddresses(to)) + .withMessage(new Message() + .withBody(new Body() + .withHtml(new Content().withCharset("UTF-8").withData(content))) + .withSubject(new Content().withCharset("UTF-8").withData(subject))) + .withSource(from); + } + + private IContext createContext(Map variables) { + Context context = new Context(); + context.setVariables(variables); + + return context; + } +} diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationController.java b/src/main/java/dynamicquad/agilehub/email/InvitationController.java new file mode 100644 index 0000000..a5a77aa --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/InvitationController.java @@ -0,0 +1,28 @@ +package dynamicquad.agilehub.email; + +import dynamicquad.agilehub.global.auth.model.Auth; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/invitations") +public class InvitationController { + + private final InvitationService invitationService; + + @PostMapping + public ResponseEntity inviteMember(@Auth AuthMember authMember, + @RequestBody ProjectInviteRequestDto.SendInviteMail sendInviteMail) { + invitationService.sendInvitation(authMember, sendInviteMail); + return ResponseEntity.ok().build(); + } + + +} diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationService.java b/src/main/java/dynamicquad/agilehub/email/InvitationService.java new file mode 100644 index 0000000..e2eda40 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/InvitationService.java @@ -0,0 +1,12 @@ +package dynamicquad.agilehub.email; + +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto; + +public interface InvitationService { + void sendInvitation(AuthMember authMember, ProjectInviteRequestDto.SendInviteMail sendInviteMail); + + //초대토큰 유효한지 확인 + void validateInvitation(String inviteToken); +} + diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java new file mode 100644 index 0000000..5c0beba --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java @@ -0,0 +1,128 @@ +package dynamicquad.agilehub.email; + +import dynamicquad.agilehub.global.exception.GeneralException; +import dynamicquad.agilehub.global.header.status.ErrorStatus; +import dynamicquad.agilehub.global.util.RandomStringUtil; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.project.controller.request.ProjectInviteRequestDto.SendInviteMail; +import dynamicquad.agilehub.project.service.MemberProjectService; +import dynamicquad.agilehub.project.service.ProjectQueryService; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class InvitationServiceImpl implements InvitationService { + + private final MemberProjectService memberProjectService; + private final RedisTemplate redisTemplate; + private final SMTPService smtpService; + private final ProjectQueryService projectQueryService; + + // 초대 코드 저장 key prefix + private static final String KEY_PREFIX = "i:"; + + // 초대 코드 상태 저장 key prefix + private static final String STATUS_PREFIX = "status:"; + + // 초대 코드 만료 시간 + private static final int EXPIRATION_MINUTES = 10; + + @Override + public void sendInvitation(AuthMember authMember, SendInviteMail sendInviteMail) { + validateMember(authMember, sendInviteMail.getProjectId()); + // 진행 중인 초대가 있는 지 확인 + String email = validateMail(sendInviteMail); + + String token = generateInviteToken(); + storeInviteToken(token, sendInviteMail); + storeInvitationStatus(email, InvitationStatus.PENDING); + + sendEmail(sendInviteMail, token); + } + + private String validateMail(SendInviteMail sendInviteMail) { + String email = sendInviteMail.getEmail(); + if (hasActiveInvitation(email)) { + throw new GeneralException(ErrorStatus.ALREADY_INVITATION); + } + else if (isEmailServiceDown(email)) { + // 이메일 서비스가 고장나 있을 때 + throw new GeneralException(ErrorStatus.EMAIL_SEND_FAIL); + } + return email; + } + + private boolean isEmailServiceDown(String email) { + String statusKey = STATUS_PREFIX + email; + String status = (String) redisTemplate.opsForValue().get(statusKey); + return status != null && (InvitationStatus.isFailed(status)); + } + + private void storeInvitationStatus(String email, InvitationStatus invitationStatus) { + String statusKey = STATUS_PREFIX + email; + // 10분 동안 이메일 상태 유지 + redisTemplate.opsForValue().set(statusKey, invitationStatus.name(), EXPIRATION_MINUTES, TimeUnit.MINUTES); + } + + private boolean hasActiveInvitation(String email) { + String statusKey = STATUS_PREFIX + email; + String status = redisTemplate.opsForValue().get(statusKey); + return status != null && (InvitationStatus.isPendingOrSending(status)); + } + + private void storeInviteToken(String token, SendInviteMail sendInviteMail) { + String tokenBase64 = RandomStringUtil.uuidToBase64(token); + String key = KEY_PREFIX + tokenBase64; + + Map fields = new HashMap<>(); + fields.put("p", String.valueOf(sendInviteMail.getProjectId())); // projectId -> p + fields.put("u", "0"); // used 상태 + + redisTemplate.opsForHash().putAll(key, fields); + redisTemplate.expire(key, EXPIRATION_MINUTES, TimeUnit.MINUTES); + } + + + private void sendEmail(SendInviteMail sendInviteMail, String token) { + final String projectName = projectQueryService.findProjectById(sendInviteMail.getProjectId()).getName(); + + Map variables = new HashMap<>(); + variables.put("inviteCode", token); + variables.put("projectName", projectName); + + smtpService.sendEmail("AgileHub 초대 메일", variables, sendInviteMail.getEmail()) + .thenRun(() -> { + storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.SENT); + log.info("이메일 전송 완료"); + }) + .exceptionally(e -> { + log.error("이메일 전송 실패", e); + storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED); + return null; + }); + } + + private void validateMember(AuthMember authMember, long projectId) { + memberProjectService.validateMemberInProject(authMember.getId(), projectId); + memberProjectService.validateMemberRole(authMember, projectId); + } + + private String generateInviteToken() { + return RandomStringUtil.generateUUID(); + } + + + @Override + public void validateInvitation(String inviteToken) { + + } +} \ No newline at end of file diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java b/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java new file mode 100644 index 0000000..3c6b590 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/InvitationStatus.java @@ -0,0 +1,26 @@ +package dynamicquad.agilehub.email; + +public enum InvitationStatus { + PENDING, // 초기 상태 + SENDING, // 발송 중 + SENT, // 발송 완료 + RETRY, // 재시도 대기 + FAILED // 최종 실패 + ; + + + // string을 전달하면 enum으로 변환 + public static InvitationStatus fromString(String status) { + return InvitationStatus.valueOf(status.toUpperCase()); + } + + public static boolean isPendingOrSending(String status) { + InvitationStatus invitationStatus = fromString(status); + return invitationStatus == PENDING || invitationStatus == SENDING; + } + + public static boolean isFailed(String status) { + InvitationStatus invitationStatus = fromString(status); + return invitationStatus == FAILED; + } +} diff --git a/src/main/java/dynamicquad/agilehub/email/SMTPService.java b/src/main/java/dynamicquad/agilehub/email/SMTPService.java new file mode 100644 index 0000000..cf34459 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/email/SMTPService.java @@ -0,0 +1,8 @@ +package dynamicquad.agilehub.email; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public interface SMTPService { + CompletableFuture sendEmail(String subject, Map variables, String... to); +} diff --git a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java index 932352c..ea8cc63 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java @@ -1,6 +1,7 @@ package dynamicquad.agilehub.global.config; import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; @@ -16,14 +17,17 @@ public class AsyncConfig { @Bean(name = "emailExecutor") public Executor emailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - executor.setCorePoolSize(5); - executor.setMaxPoolSize(30); - executor.setQueueCapacity(50); + executor.setCorePoolSize(4); // IO 바운드 작업: 코어수 * 2 + executor.setMaxPoolSize(44); // CorePoolSize * (1 + 대기시간/서비스시간) + executor.setQueueCapacity(80); // (MaxPoolSize - CorePoolSize) * 2 executor.setThreadNamePrefix("email-"); executor.setWaitForTasksToCompleteOnShutdown(WAIT_TASK_COMPLETE); executor.setAwaitTerminationSeconds(AWAIT_TERMINATION_SECONDS); + // 거부 정책 설정 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); - + return executor; } } diff --git a/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java b/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java new file mode 100644 index 0000000..28f4bb0 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/config/DefaultSESConfig.java @@ -0,0 +1,33 @@ +package dynamicquad.agilehub.global.config; + +import com.amazonaws.ClientConfiguration; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService; +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class DefaultSESConfig { + @Value("${aws.ses.accessKey}") + private String accessKey; + + @Value("${aws.ses.secretKey}") + private String secretKey; + + @Bean + public AmazonSimpleEmailService amazonSimpleEmailService() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonSimpleEmailServiceClientBuilder.standard() + .withRegion(Regions.AP_NORTHEAST_2) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .withClientConfiguration(new ClientConfiguration() + .withConnectionTimeout(3000) + .withSocketTimeout(5000) + .withRequestTimeout(10000)) + .build(); + } +} diff --git a/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java b/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java new file mode 100644 index 0000000..3dcaeae --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/config/TemplateConfig.java @@ -0,0 +1,18 @@ +package dynamicquad.agilehub.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.spring6.SpringTemplateEngine; +import org.thymeleaf.spring6.templateresolver.SpringResourceTemplateResolver; + +@Configuration +public class TemplateConfig { + @Bean + public TemplateEngine htmlTemplateEngine(SpringResourceTemplateResolver springResourceTemplateResolver) { + TemplateEngine templateEngine = new SpringTemplateEngine(); + templateEngine.addTemplateResolver(springResourceTemplateResolver); + + return templateEngine; + } +} diff --git a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java index 62ffcd4..ad681ea 100644 --- a/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java +++ b/src/main/java/dynamicquad/agilehub/global/header/status/ErrorStatus.java @@ -60,11 +60,14 @@ public enum ErrorStatus implements BaseStatus { // Email Error EMAIL_NOT_SENT(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_5001", "이메일이 정상적으로 송신되지 않았습니다."), INVITE_CODE_NOT_EXIST(HttpStatus.BAD_REQUEST, "EMAIL_4001", "초대 코드를 찾을 수 없습니다."), + ALREADY_INVITATION(HttpStatus.BAD_REQUEST, "EMAIL_4002", "이미 진행 중인 초대가 있습니다"), + EMAIL_SEND_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "EMAIL_4003", "이메일 서비스에 문제가 발생했습니다. 나중에 다시 시도해주세요."), // Optimistic Lock Exception OPTIMISTIC_LOCK_EXCEPTION(HttpStatus.LOCKED, "LOCK_4000", "다른 사용자가 이미 수정했습니다. 새로 고침 후 다시 시도해주세요."), OPTIMISTIC_LOCK_EXCEPTION_ISSUE_NUMBER(HttpStatus.LOCKED, "LOCK_4001", "이슈 번호 생성 실패. 다시 시도해주세요."); + private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java b/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java index 1c9837b..03fc5f6 100644 --- a/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java +++ b/src/main/java/dynamicquad/agilehub/global/util/RandomStringUtil.java @@ -1,7 +1,10 @@ package dynamicquad.agilehub.global.util; +import java.nio.ByteBuffer; import java.security.SecureRandom; +import java.util.Base64; import java.util.Random; +import java.util.UUID; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -13,6 +16,7 @@ public class RandomStringUtil { private static final String ALL_CHARS = CHAR_UPPERCASE + CHAR_LOWERCASE + CHAR_DIGITS + CHAR_SPECIAL; + private RandomStringUtil() { } @@ -29,4 +33,26 @@ public static String generateRandomKey(int keyLength) { return sb.toString(); } + public static String generateUUID() { + return java.util.UUID.randomUUID().toString(); + } + + public static String uuidToBase64(String str) { + UUID uuid = UUID.fromString(str); + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array()); + } + + public static String base64ToUUID(String str) { + byte[] bytes = Base64.getDecoder().decode(str); + ByteBuffer bb = ByteBuffer.wrap(bytes); + UUID uuid = new UUID(bb.getLong(), bb.getLong()); + + return uuid.toString(); + } + + } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bf9bcc0..82bcfbe 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -52,6 +52,10 @@ aws: directory: /images workingDirectory: issue: issue + ses: + accessKey: ${AWS_SES_ACCESS_KEY} + secretKey: ${AWS_SES_SECRET_KEY} + from: ${AWS_SES_FROM} openai: diff --git a/src/main/resources/templates/invite.html b/src/main/resources/templates/invite.html index 5b4f11a..78615a4 100644 --- a/src/main/resources/templates/invite.html +++ b/src/main/resources/templates/invite.html @@ -123,12 +123,12 @@

관리자 및 팀과 함께 작업 계획 및 이슈 추적을 시작합니다
작업을 공유하고 팀이 무엇을 하고 있는지 볼 수 있습니다

- diff --git a/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java b/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java new file mode 100644 index 0000000..b9ffaa6 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java @@ -0,0 +1,26 @@ +package dynamicquad.agilehub.email; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class AmazonSESServiceTest { + @Autowired + private AmazonSESService amazonSESService; + + @Test + @DisplayName("Amazon SES를 이용한 이메일 발송 테스트") + void sendEmailTest() { + // given + String subject = "테스트 이메일 발송"; + String to = "evobusiness99@gmail.com"; + Map variables = Map.of("projectName", "테스트 프로젝트", "inviteCode", "123456"); + + amazonSESService.sendEmail(subject, variables, to); + } +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/global/util/RandomStringUtilTest.java b/src/test/java/dynamicquad/agilehub/global/util/RandomStringUtilTest.java new file mode 100644 index 0000000..4ae93bc --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/global/util/RandomStringUtilTest.java @@ -0,0 +1,85 @@ +package dynamicquad.agilehub.global.util; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +class RandomStringUtilTest { + // SecureRandom 32자와 UUID.randomUUID() 성능 비교 + + private static final int ITERATIONS = 1_000_000; + private static final SecureRandom secureRandom = new SecureRandom(); + private static final String CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + public static void main(String[] args) { + // Test different lengths for SecureRandom + int[] lengths = {16, 32, 36, 64}; // 36은 UUID 길이와 동일 + + for (int length : lengths) { + System.out.println("\nTesting with length: " + length); + + // Test generation time + long startTime = System.currentTimeMillis(); + List secureRandomTokens = generateSecureRandomTokens(length); + long secureRandomTime = System.currentTimeMillis() - startTime; + + startTime = System.currentTimeMillis(); + List uuidTokens = generateUuidTokens(); + long uuidTime = System.currentTimeMillis() - startTime; + + // Test collision rate + int secureRandomCollisions = checkCollisions(secureRandomTokens); + int uuidCollisions = checkCollisions(uuidTokens); + + // Calculate memory usage (approximate) + long secureRandomMemory = calculateMemoryUsage(secureRandomTokens); + long uuidMemory = calculateMemoryUsage(uuidTokens); + + // Print results + System.out.println("SecureRandom Performance:"); + System.out.println(" Generation time: " + secureRandomTime + "ms"); + System.out.println(" Collisions: " + secureRandomCollisions); + System.out.println(" Memory usage: " + secureRandomMemory + " bytes"); + + System.out.println("UUID Performance:"); + System.out.println(" Generation time: " + uuidTime + "ms"); + System.out.println(" Collisions: " + uuidCollisions); + System.out.println(" Memory usage: " + uuidMemory + " bytes"); + } + } + + private static List generateSecureRandomTokens(int length) { + List tokens = new ArrayList<>(ITERATIONS); + for (int i = 0; i < ITERATIONS; i++) { + StringBuilder token = new StringBuilder(length); + for (int j = 0; j < length; j++) { + token.append(CHARS.charAt(secureRandom.nextInt(CHARS.length()))); + } + tokens.add(token.toString()); + } + return tokens; + } + + private static List generateUuidTokens() { + List tokens = new ArrayList<>(ITERATIONS); + for (int i = 0; i < ITERATIONS; i++) { + tokens.add(UUID.randomUUID().toString()); + } + return tokens; + } + + private static int checkCollisions(List tokens) { + Set uniqueTokens = new HashSet<>(tokens); + return tokens.size() - uniqueTokens.size(); + } + + private static long calculateMemoryUsage(List tokens) { + // Rough estimation of memory usage + return tokens.get(0).length() * Character.BYTES * (long) tokens.size(); + } + + +} \ No newline at end of file From b61298255c2a1433f699ef70c597cc91613ba19c Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 20:45:41 +0900 Subject: [PATCH 29/45] refactor: log error level info -> error in retryAspect --- .../java/dynamicquad/agilehub/email/AmazonSESService.java | 3 +++ .../java/dynamicquad/agilehub/issue/aspect/RetryAspect.java | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java index d017965..4e3dec5 100644 --- a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class AmazonSESService implements SMTPService { private final AmazonSimpleEmailService amazonSimpleEmailService; @@ -41,6 +43,7 @@ public CompletableFuture sendEmail(String subject, Map var amazonSimpleEmailService.sendEmail(request); } catch (Exception e) { + log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); } }); diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java index c7d2c1b..521c78d 100644 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java @@ -27,7 +27,7 @@ public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { while (attempts < maxAttempts) { try { - log.info("시도 #{}", attempts + 1); + log.error("시도 #{}", attempts + 1); return joinPoint.proceed(); } catch (Exception e) { attempts++; @@ -45,7 +45,7 @@ public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { throw e; } - log.warn("재시도 {}/{}", attempts, maxAttempts); + log.error("재시도 {}/{}", attempts, maxAttempts); Thread.sleep(retry.delay()); } } From 11b99b71ffc8ebbe6467db17b6c273e01a05329d Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 21:11:30 +0900 Subject: [PATCH 30/45] =?UTF-8?q?refactor:=20=20async=20retry=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/email/AmazonSESService.java | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java index 4e3dec5..9ba1749 100644 --- a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -33,24 +33,29 @@ public class AmazonSESService implements SMTPService { @Override @Async("emailExecutor") - @Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) public CompletableFuture sendEmail(String subject, Map variables, String... to) { // Amazon SES를 이용한 이메일 발송 return CompletableFuture.runAsync(() -> { - try { - String content = htmlTemplateEngine.process("invite", createContext(variables)); - SendEmailRequest request = createSendEmailRequest(subject, content, to); - - amazonSimpleEmailService.sendEmail(request); - } catch (Exception e) { - log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); - throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); - } + doSendEmail(subject, variables, to); }); } + + @Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) + public void doSendEmail(String subject, Map variables, String[] to) { + try { + String content = htmlTemplateEngine.process("invite", createContext(variables)); + SendEmailRequest request = createSendEmailRequest(subject, content, to); + + amazonSimpleEmailService.sendEmail(request); + } catch (Exception e) { + log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); + throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); + } + } + private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) { return new SendEmailRequest() .withDestination(new Destination().withToAddresses(to)) From 3f4f4e1c033a081f014202322ff426594f8eebf3 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 21:43:45 +0900 Subject: [PATCH 31/45] =?UTF-8?q?refactor:=20=20retryable=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ .../dynamicquad/agilehub/email/AmazonSESService.java | 9 +++++++-- .../dynamicquad/agilehub/issue/aspect/RetryAspect.java | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 41cd867..dd88b10 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'com.amazonaws:aws-java-sdk-ses:1.12.408' + // retry + implementation 'org.springframework.retry:spring-retry:2.0.10' + // thymeleaf implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java index 9ba1749..73e2769 100644 --- a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -8,12 +8,13 @@ import com.amazonaws.services.simpleemail.model.SendEmailRequest; import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.aspect.Retry; import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.thymeleaf.TemplateEngine; @@ -33,8 +34,13 @@ public class AmazonSESService implements SMTPService { @Override @Async("emailExecutor") + //@Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) + @Retryable(retryFor = {GeneralException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500)) public CompletableFuture sendEmail(String subject, Map variables, String... to) { // Amazon SES를 이용한 이메일 발송 + // 재시도 로직 + int attempts = 0; + log.error("시도 #{}", attempts + 1); return CompletableFuture.runAsync(() -> { doSendEmail(subject, variables, to); }); @@ -43,7 +49,6 @@ public CompletableFuture sendEmail(String subject, Map var } - @Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) public void doSendEmail(String subject, Map variables, String[] to) { try { String content = htmlTemplateEngine.process("invite", createContext(variables)); diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java index 521c78d..a2a5e9c 100644 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java +++ b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component; @Aspect -@Order(Ordered.HIGHEST_PRECEDENCE) +@Order(Ordered.LOWEST_PRECEDENCE) @Component @Slf4j public class RetryAspect { From 019b67d4f0e26e1763f84e7bfcfb421852b7f617 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 22:21:35 +0900 Subject: [PATCH 32/45] =?UTF-8?q?refactor:=20=20retryable=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/email/AmazonSESService.java | 29 +++++++------------ .../agilehub/email/InvitationServiceImpl.java | 3 +- .../agilehub/global/config/AsyncConfig.java | 2 ++ 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java index 73e2769..56fb37a 100644 --- a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -34,31 +34,22 @@ public class AmazonSESService implements SMTPService { @Override @Async("emailExecutor") - //@Retry(maxRetries = 3, retryFor = {GeneralException.class}, delay = 500) @Retryable(retryFor = {GeneralException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500)) public CompletableFuture sendEmail(String subject, Map variables, String... to) { - // Amazon SES를 이용한 이메일 발송 - // 재시도 로직 - int attempts = 0; - log.error("시도 #{}", attempts + 1); - return CompletableFuture.runAsync(() -> { - doSendEmail(subject, variables, to); - }); - - } + return CompletableFuture.runAsync(() -> { + try { + String content = htmlTemplateEngine.process("invite", createContext(variables)); + SendEmailRequest request = createSendEmailRequest(subject, content, to); + amazonSimpleEmailService.sendEmail(request); + } catch (Exception e) { + log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); + throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); + } + }); - public void doSendEmail(String subject, Map variables, String[] to) { - try { - String content = htmlTemplateEngine.process("invite", createContext(variables)); - SendEmailRequest request = createSendEmailRequest(subject, content, to); - amazonSimpleEmailService.sendEmail(request); - } catch (Exception e) { - log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); - throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); - } } private SendEmailRequest createSendEmailRequest(String subject, String content, String... to) { diff --git a/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java index 5c0beba..5f18ed7 100644 --- a/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java +++ b/src/main/java/dynamicquad/agilehub/email/InvitationServiceImpl.java @@ -105,7 +105,8 @@ private void sendEmail(SendInviteMail sendInviteMail, String token) { log.info("이메일 전송 완료"); }) .exceptionally(e -> { - log.error("이메일 전송 실패", e); + // 여기서는 모든 재시도가 실패한 후의 최종 실패 처리 + log.error("이메일 전송 최종 실패", e); storeInvitationStatus(sendInviteMail.getEmail(), InvitationStatus.FAILED); return null; }); diff --git a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java index ea8cc63..3d6e678 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/AsyncConfig.java @@ -4,11 +4,13 @@ import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration @EnableAsync +@EnableRetry public class AsyncConfig { private static final boolean WAIT_TASK_COMPLETE = true; From 1009062ba053d657855877ae30b5127bf93d099c Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 22:24:29 +0900 Subject: [PATCH 33/45] =?UTF-8?q?refactor:=20=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B0=8D=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dynamicquad/agilehub/email/AmazonSESService.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java index 56fb37a..49d005a 100644 --- a/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java +++ b/src/main/java/dynamicquad/agilehub/email/AmazonSESService.java @@ -8,13 +8,16 @@ import com.amazonaws.services.simpleemail.model.SendEmailRequest; import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; +import java.util.Arrays; import java.util.Map; import java.util.concurrent.CompletableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.retry.RetryContext; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; +import org.springframework.retry.support.RetrySynchronizationManager; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.thymeleaf.TemplateEngine; @@ -37,6 +40,10 @@ public class AmazonSESService implements SMTPService { @Retryable(retryFor = {GeneralException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500)) public CompletableFuture sendEmail(String subject, Map variables, String... to) { + // RetryContext를 통해 현재 시도 횟수 가져오기 + RetryContext context = RetrySynchronizationManager.getContext(); + int attempt = context != null ? context.getRetryCount() + 1 : 1; + return CompletableFuture.runAsync(() -> { try { String content = htmlTemplateEngine.process("invite", createContext(variables)); @@ -44,6 +51,9 @@ public CompletableFuture sendEmail(String subject, Map var amazonSimpleEmailService.sendEmail(request); } catch (Exception e) { + log.error("이메일 발송 실패 (시도 #{}) - 수신자: {}, 에러: {}", + attempt, Arrays.toString(to), e.getMessage()); + log.error("AmazonSESService 이메일 발송 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); } From e1bc9f78419665cb1ae49d14e55196b96b2837d7 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 23:00:39 +0900 Subject: [PATCH 34/45] =?UTF-8?q?feat:=20visualVM=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend-prod-cd.yml | 3 ++- Dockerfile | 13 ++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/backend-prod-cd.yml b/.github/workflows/backend-prod-cd.yml index 81b10d6..af5f6da 100644 --- a/.github/workflows/backend-prod-cd.yml +++ b/.github/workflows/backend-prod-cd.yml @@ -49,5 +49,6 @@ jobs: docker rm -f agilehub-backend || true docker pull ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} docker run -v /var/log/backend:/app/logs \ + -e HOST_IP=${{ secrets.NCP_HOST }} \ --env-file .env \ - -d -p 8080:8080 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} + -d -p 8080:8080 -p 9010:9010 --name agilehub-backend ${{ env.DOCKER_HUB_REPOSITORY }}:${{ github.sha }} diff --git a/Dockerfile b/Dockerfile index 2a95e43..dc0ea5f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,5 +27,16 @@ WORKDIR /app COPY --from=build /app/build/libs/*.jar /app/agile.jar EXPOSE 8080 +EXPOSE 9010 + ENTRYPOINT ["java"] -CMD ["-jar","-Dspring.profiles.active=prod","agile.jar"] +CMD ["-jar", \ + "-Dspring.profiles.active=prod", \ + "-Dcom.sun.management.jmxremote=true", \ + "-Dcom.sun.management.jmxremote.local.only=false", \ + "-Dcom.sun.management.jmxremote.port=9010", \ + "-Dcom.sun.management.jmxremote.rmi.port=9010", \ + "-Dcom.sun.management.jmxremote.ssl=false", \ + "-Dcom.sun.management.jmxremote.authenticate=false", \ + "-Djava.rmi.server.hostname=223.130.129.226", \ + "agile.jar"] From 8632a7078e937720fa5626158a88a8eea911433e Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 23:46:03 +0900 Subject: [PATCH 35/45] =?UTF-8?q?feat:=20hikaripool,=20tomcat=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 82bcfbe..a438b54 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: datasource: hikari: # 풀 사이즈 설정 - maximum-pool-size: 8 - minimum-idle: 8 + maximum-pool-size: 100 + minimum-idle: 100 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 @@ -26,7 +26,7 @@ server: accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 threads: - max: 10 # 최대 스레드 수 + max: 200 # 최대 스레드 수 min-spare: 10 # 초기 스레드 풀 사이즈 From 357ac6aebf90925333efd4a0f153d0cfc2d901e0 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Tue, 24 Dec 2024 23:53:43 +0900 Subject: [PATCH 36/45] =?UTF-8?q?feat:=20hikaripool,=20tomcat=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a438b54..496f72d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: datasource: hikari: # 풀 사이즈 설정 - maximum-pool-size: 100 - minimum-idle: 100 + maximum-pool-size: 200 + minimum-idle: 200 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 @@ -26,7 +26,7 @@ server: accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 threads: - max: 200 # 최대 스레드 수 + max: 300 # 최대 스레드 수 min-spare: 10 # 초기 스레드 풀 사이즈 From 4701006cae3bd540f2daa9c0a893e6bb8082e546 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Wed, 25 Dec 2024 00:13:14 +0900 Subject: [PATCH 37/45] =?UTF-8?q?feat:=20hikaripool,=20tomcat=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 496f72d..a138508 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,8 +12,8 @@ spring: datasource: hikari: # 풀 사이즈 설정 - maximum-pool-size: 200 - minimum-idle: 200 + maximum-pool-size: 400 + minimum-idle: 400 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 @@ -26,8 +26,8 @@ server: accept-count: 100 # max-connections를 초과하는 요청에 대한 대기열 크기, 대기열이 가득 차면 추가 요청은 거절됨, OS 레벨에서 관리되는 설정 max-connections: 8192 # 서버가 동시에 유지할 수 있는 최대 연결 수, 실제 활성화된 TCP 연결의 수가 아님, 시스템이 할당한 소켓 파일 디스크립터의 수를 의미, 연결이 종료되어도 TIME_WAIT 상태 때문에 파일 디스크립터는 바로 해제되지 않음 threads: - max: 300 # 최대 스레드 수 - min-spare: 10 # 초기 스레드 풀 사이즈 + max: 500 # 최대 스레드 수 + min-spare: 500 # 초기 스레드 풀 사이즈 From 381423ebf3b756edd1eccdf13cff0943375a5296 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Wed, 25 Dec 2024 16:18:57 +0900 Subject: [PATCH 38/45] =?UTF-8?q?feat:=20test=20=EB=B0=8F=20security=20?= =?UTF-8?q?=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend_ci.yml | 11 ++ build.gradle | 3 + docker-compose.yml | 1 + .../global/auth/filter/JwtAuthFilter.java | 5 +- .../global/auth/filter/MockJwtAuthFilter.java | 30 +++++ .../auth/filter/OAuth2SuccessHandler.java | 8 +- .../auth/util/MemberArgumentResolver.java | 48 ++++--- .../global/config/SpringSecurityConfig.java | 109 ++++++++------- .../agilehub/global/config/WebConfig.java | 6 +- .../issue/service/command/IssueService.java | 3 +- src/main/resources/application.yml | 2 +- .../agilehub/config/RedisTestContainer.java | 33 +++++ .../issue/controller/IssueControllerTest.java | 124 ------------------ .../issue/service/IssueServiceTest.java | 108 --------------- .../controller/ProjectControllerTest.java | 3 +- .../project/service/ProjectServiceTest.java | 18 --- .../controller/SprintControllerTest.java | 7 +- 17 files changed, 191 insertions(+), 328 deletions(-) create mode 100644 src/main/java/dynamicquad/agilehub/global/auth/filter/MockJwtAuthFilter.java create mode 100644 src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/controller/IssueControllerTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java diff --git a/.github/workflows/backend_ci.yml b/.github/workflows/backend_ci.yml index b7881ae..c30eadc 100644 --- a/.github/workflows/backend_ci.yml +++ b/.github/workflows/backend_ci.yml @@ -11,12 +11,23 @@ jobs: build: runs-on: ubuntu-latest + + # Docker 사용을 위한 서비스 컨테이너 추가 + services: + docker: + image: docker:dind + options: --privileged + ports: + - 2375:2375 env: JWT_SECRET: ${{ secrets.JWT_SECRET }} REDIS_HOST: localhost MAIL_USERNAME: 1234 MAIL_PASSWORD: 1234 OPEN_API_KEY: 1234 + # Docker 데몬 연결을 위한 환경변수 추가 + DOCKER_HOST: tcp://localhost:2375 + TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock steps: - name: 레포지토리를 가져옵니다 diff --git a/build.gradle b/build.gradle index dd88b10..d3cd517 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,9 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // test-containers + testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2' + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" diff --git a/docker-compose.yml b/docker-compose.yml index abe88a0..af2ddb2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci + - --max_connections=500 networks: - app-network restart: always diff --git a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java index 775c24a..858e10d 100644 --- a/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java +++ b/src/main/java/dynamicquad/agilehub/global/auth/filter/JwtAuthFilter.java @@ -15,14 +15,17 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -//@Component -> 필터비활성화 +@Component @RequiredArgsConstructor +@Profile("prod") @Slf4j public class JwtAuthFilter extends OncePerRequestFilter { diff --git a/src/main/java/dynamicquad/agilehub/global/auth/filter/MockJwtAuthFilter.java b/src/main/java/dynamicquad/agilehub/global/auth/filter/MockJwtAuthFilter.java new file mode 100644 index 0000000..018bea1 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/global/auth/filter/MockJwtAuthFilter.java @@ -0,0 +1,30 @@ +package dynamicquad.agilehub.global.auth.filter; + +import dynamicquad.agilehub.global.auth.service.RefreshTokenRedisService; +import dynamicquad.agilehub.global.auth.util.JwtUtil; +import dynamicquad.agilehub.member.service.MemberQueryService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile({"security-off", "test", "local"}) +public class MockJwtAuthFilter extends JwtAuthFilter { + + public MockJwtAuthFilter(JwtUtil jwtUtil, + MemberQueryService memberQueryService, + RefreshTokenRedisService redisService) { + super(jwtUtil, memberQueryService, redisService); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + filterChain.doFilter(request, response); + } + +} diff --git a/src/main/java/dynamicquad/agilehub/global/auth/filter/OAuth2SuccessHandler.java b/src/main/java/dynamicquad/agilehub/global/auth/filter/OAuth2SuccessHandler.java index 7225b82..1fcaf2b 100644 --- a/src/main/java/dynamicquad/agilehub/global/auth/filter/OAuth2SuccessHandler.java +++ b/src/main/java/dynamicquad/agilehub/global/auth/filter/OAuth2SuccessHandler.java @@ -49,7 +49,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { Optional redirectUri = CookieUtil.getCookie(request, REDIRECT_URL_PARAM_COOKIE_NAME) - .map(cookie -> cookie.getValue()); + .map(cookie -> cookie.getValue()); clearAuthenticationAttributes(request, response); return redirectUri.orElse(getDefaultTargetUrl()); @@ -61,8 +61,8 @@ private void clearAuthenticationAttributes(HttpServletRequest request, HttpServl private String getRedirectUrl(String targetUrl, GeneratedToken generatedToken) { return UriComponentsBuilder.fromUriString(targetUrl) - .queryParam("accessToken", generatedToken.getAccessToken()) - .build().toUriString(); + .queryParam("accessToken", generatedToken.getAccessToken()) + .build().toUriString(); } private GeneratedToken generateMemberToken(Authentication authentication) { @@ -71,7 +71,7 @@ private GeneratedToken generateMemberToken(Authentication authentication) { String distinctId = principal.getId(); String name = principal.getName(); String role = principal.getAuthorities().stream().findFirst() - .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_ROLE_NOT_EXIST)).getAuthority(); + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_ROLE_NOT_EXIST)).getAuthority(); GeneratedToken token = jwtUtil.generateToken(name, role, provider, distinctId); diff --git a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java index 50f7281..5ad333d 100644 --- a/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java +++ b/src/main/java/dynamicquad/agilehub/global/auth/util/MemberArgumentResolver.java @@ -1,45 +1,61 @@ package dynamicquad.agilehub.global.auth.util; import dynamicquad.agilehub.global.auth.model.Auth; +import dynamicquad.agilehub.global.auth.model.SecurityMember; +import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.support.WebDataBinderFactory; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; + public class MemberArgumentResolver implements HandlerMethodArgumentResolver { + private final String activeProfile; + + public MemberArgumentResolver(String activeProfile) { + this.activeProfile = activeProfile; + } + @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(Auth.class) && parameter.getParameterType().equals(AuthMember.class); } -// @Override -// public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, -// NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { -// Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); -// -// SecurityMember principal = (SecurityMember) authentication.getPrincipal(); -// Member member = principal.getMember(); -// -// return AuthMember.builder() -// .id(member.getId()) -// .name(member.getName()) -// .profileImageUrl(member.getProfileImageUrl()) -// .build(); -// } - @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - // 테스트용 유저1 -> 모든 API에 해당 유저만 진입됨 -> API 진입 유저 1 이 여러번 생성과 수정과 조회 하는 것도 의미있다고 봅니다 + if (activeProfile.equals("prod")) { + return resolveProductionAuthMember(); + } + + return resolveTestAuthMember(); + } + + private Object resolveTestAuthMember() { return AuthMember.builder() .id(1L) .name("User1") .profileImageUrl("adada") .build(); } + + private Object resolveProductionAuthMember() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + SecurityMember principal = (SecurityMember) authentication.getPrincipal(); + Member member = principal.getMember(); + + return AuthMember.builder() + .id(member.getId()) + .name(member.getName()) + .profileImageUrl(member.getProfileImageUrl()) + .build(); + } } diff --git a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java index 71f1f79..2da7576 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/SpringSecurityConfig.java @@ -1,21 +1,31 @@ package dynamicquad.agilehub.global.config; +import dynamicquad.agilehub.global.auth.filter.JwtAuthFilter; +import dynamicquad.agilehub.global.auth.filter.JwtExceptionFilter; +import dynamicquad.agilehub.global.auth.filter.OAuth2SuccessHandler; +import dynamicquad.agilehub.global.auth.repository.CustomAuthorizationRequestRepository; +import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Collections; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.filter.OncePerRequestFilter; @Configuration @@ -23,56 +33,58 @@ @Slf4j public class SpringSecurityConfig { -// private final OAuth2SuccessHandler oAuth2SuccessHandler; -// private final JwtAuthFilter jwtAuthFilter; -// -// private final CustomOAuth2UserService customOAuth2UserService; -// -// @Bean -// public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { -// -// // csrf disable 처리 : 추후 설정 변경 필요 -// http -// .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) -// .cors(cors -> cors.configurationSource(request -> { -// CorsConfiguration config = new CorsConfiguration(); -// config.setAllowedOriginPatterns(Collections.singletonList("*")); -// config.setAllowedMethods(Collections.singletonList("*")); -// config.setAllowedHeaders(Collections.singletonList("*")); -// config.setAllowCredentials(true); -// config.setExposedHeaders(List.of("Authorization")); -// config.setMaxAge(3600L); -// return config; -// })) -// .csrf(AbstractHttpConfigurer::disable) -// .formLogin(AbstractHttpConfigurer::disable) -// .httpBasic(AbstractHttpConfigurer::disable) -// .logout(AbstractHttpConfigurer::disable) -// // requestMatchers 설정 -// .authorizeHttpRequests(requests -> requests -// .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**", -// "/actuator/**", -// "/favicon.ico") -// .permitAll() -// .anyRequest().authenticated() -// ) -// -// // oauth2 설정 -// .oauth2Login(customizer -> customizer -// .authorizationEndpoint(authorization -> authorization -// .authorizationRequestRepository(new CustomAuthorizationRequestRepository())) -// .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) -// .successHandler(oAuth2SuccessHandler) -// ) -// -// // jwt 설정 -// .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) -// .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class); -// -// return http.build(); -// } + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final JwtAuthFilter jwtAuthFilter; @Bean + @Profile("prod") + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + // csrf disable 처리 : 추후 설정 변경 필요 + http + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .cors(cors -> cors.configurationSource(request -> { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowCredentials(true); + config.setExposedHeaders(List.of("Authorization")); + config.setMaxAge(3600L); + return config; + })) + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + // requestMatchers 설정 + .authorizeHttpRequests(requests -> requests + .requestMatchers("/oauth2/**", "/auth/success/**", "*/api-docs/**", "/swagger-ui/**", + "/actuator/**", + "/favicon.ico") + .permitAll() + .anyRequest().authenticated() + ) + + // oauth2 설정 + .oauth2Login(customizer -> customizer + .authorizationEndpoint(authorization -> authorization + .authorizationRequestRepository(new CustomAuthorizationRequestRepository())) + .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + ) + + // jwt 설정 + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtExceptionFilter(), JwtAuthFilter.class); + + return http.build(); + } + + + @Bean + @Profile({"security-off", "test", "local"}) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 기존의 모든 필터 비활성화 http @@ -100,6 +112,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } @Bean + @Profile({"security-off", "test", "local"}) public WebSecurityCustomizer webSecurityCustomizer() { return (web) -> web.ignoring() .requestMatchers("/**"); // 모든 경로에 대해 시큐리티 무시 diff --git a/src/main/java/dynamicquad/agilehub/global/config/WebConfig.java b/src/main/java/dynamicquad/agilehub/global/config/WebConfig.java index d04e476..d1993cb 100644 --- a/src/main/java/dynamicquad/agilehub/global/config/WebConfig.java +++ b/src/main/java/dynamicquad/agilehub/global/config/WebConfig.java @@ -6,6 +6,7 @@ import dynamicquad.agilehub.global.util.StringToIssueTypeConverter; import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -15,6 +16,9 @@ @Slf4j public class WebConfig implements WebMvcConfigurer { + @Value("${spring.profiles.active}") + private String activeProfile; + @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToIssueTypeConverter()); @@ -24,6 +28,6 @@ public void addFormatters(FormatterRegistry registry) { @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(new MemberArgumentResolver()); + resolvers.add(new MemberArgumentResolver(activeProfile)); } } diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java index 8dd7529..25ef81b 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java @@ -63,8 +63,9 @@ public void deleteIssue(String key, Long issueId, AuthMember authMember) { validateMemberInProject(key, authMember); Issue issue = issueValidator.findIssue(issueId); - issueRepository.delete(issue); issueNumberGenerator.decrement(key); + issueRepository.delete(issue); + } @Transactional diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a138508..1d84e14 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -13,7 +13,7 @@ spring: hikari: # 풀 사이즈 설정 maximum-pool-size: 400 - minimum-idle: 400 + minimum-idle: 20 # 시간 관련 설정 max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 diff --git a/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java b/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java new file mode 100644 index 0000000..1913654 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java @@ -0,0 +1,33 @@ +package dynamicquad.agilehub.config; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@DisplayName("Redis Test Container") +@ActiveProfiles("test") +@Configuration +public class RedisTestContainer { + private static final String REDIS_DOCKER_IMAGE = "redis:7.0"; + + static { + try { + GenericContainer REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE)) + .withExposedPorts(6379) + .withReuse(true) + .withStartupTimeout(Duration.ofSeconds(60)); // 60초로 설정 + + REDIS_CONTAINER.start(); + + System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost()); + System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString()); + } catch (Exception e) { + throw new RuntimeException("Redis Container 시작 실패", e); + } + } + + +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/controller/IssueControllerTest.java b/src/test/java/dynamicquad/agilehub/issue/controller/IssueControllerTest.java deleted file mode 100644 index 1e054ac..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/controller/IssueControllerTest.java +++ /dev/null @@ -1,124 +0,0 @@ -package dynamicquad.agilehub.issue.controller; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import dynamicquad.agilehub.global.auth.filter.OAuth2SuccessHandler; -import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService; -import dynamicquad.agilehub.global.auth.service.RefreshTokenRedisService; -import dynamicquad.agilehub.global.auth.util.JwtUtil; -import dynamicquad.agilehub.global.auth.util.MemberArgumentResolver; -import dynamicquad.agilehub.global.config.SpringSecurityConfig; -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.service.command.IssueService; -import dynamicquad.agilehub.issue.service.query.IssueQueryService; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; -import dynamicquad.agilehub.member.service.MemberQueryService; -import dynamicquad.agilehub.project.service.ProjectQueryService; -import jakarta.servlet.http.HttpServletRequest; -import java.time.LocalDate; -import java.util.Optional; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -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.test.context.ActiveProfiles; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.result.MockMvcResultHandlers; - -@ActiveProfiles("test") -@WebMvcTest(IssueController.class) -@Import({SpringSecurityConfig.class, MemberArgumentResolver.class}) -class IssueControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private IssueService issueService; - - @MockBean - private IssueQueryService issueQueryService; - - @MockBean - CustomOAuth2UserService customOAuth2UserService; - @MockBean - OAuth2SuccessHandler oAuth2SuccessHandler; - - @MockBean - MemberQueryService memberQueryService; - @MockBean - private ProjectQueryService projectQueryService; - @MockBean - JwtUtil jwtUtil; - @MockBean - RefreshTokenRedisService redisService; - - @BeforeEach - public void setUp() { - Mockito.when(jwtUtil.extractAccessToken(any(HttpServletRequest.class))) - .thenReturn(Optional.of("validAccessToken")); - - Mockito.when(jwtUtil.verifyToken("validAccessToken")) - .thenReturn(true); - - Mockito.when(memberQueryService.findBySocialProviderAndDistinctId(any(), any())) - .thenReturn(Member.createPojoByAuthMember(1L, "testUser")); - } - - @Test - void 정상적인_이슈를_생성하면_해당_이슈를_가진_URL을_반환한다() throws Exception { - - String token = "token"; - - //given - IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue.builder() - .title("이슈제목") - .type(IssueType.EPIC) - .status(IssueStatus.DO) - .content("이슈내용") - .files(null) - .startDate(LocalDate.of(2021, 1, 1)) - .endDate(LocalDate.of(2021, 1, 2)) - .assigneeId(1L) - .build(); - AuthMember authMember = AuthMember.builder() - .id(1L) - .name("testUser") - .profileImageUrl("profile") - .build(); - - String key = "project"; - when(issueService.createIssue(key, request, authMember)).thenReturn(1L); - - mockMvc.perform(post("/projects/" + key + "/issues") - .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) - .header("Authorization", "Bearer " + token) - .param("title", request.getTitle()) - .param("type", request.getType().name()) - .param("status", request.getStatus().name()) - .param("content", request.getContent()) - .param("startDate", request.getStartDate().toString()) - .param("endDate", request.getEndDate().toString()) - .param("assigneeId", request.getAssigneeId().toString())) - .andDo(MockMvcResultHandlers.print()) - .andExpect(status().isCreated()) - .andExpect(header().exists("Location")) - .andExpect(header().string("Location", "/projects/" + key + "/issues/1")) - .andExpect(jsonPath("$.code").value("COMMON_201")); - - - } - -} diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java deleted file mode 100644 index 4f0a92c..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/IssueServiceTest.java +++ /dev/null @@ -1,108 +0,0 @@ -package dynamicquad.agilehub.issue.service; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.issue.service.command.IssueService; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class IssueServiceTest { - - @Autowired - private IssueService issueService; - - @PersistenceContext - EntityManager em; - - @Transactional - @Test - void 에픽이_지워지면_스토리_테스크_모두_지워진다() { - // given - Project project1 = createProject("프로젝트1", "project12311"); - em.persist(project1); - - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); - Story story1P1 = createStory("스토리1", "스토리1 내용", project1, epic2P1); - em.persist(story1P1); - Story story2P1 = createStory("스토리2", "스토리2 내용", project1, epic2P1); - em.persist(story2P1); - - Member member = Member.builder().name("member").build(); - em.persist(member); - - MemberProject memberProject = MemberProject.builder() - .member(member) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject); - - AuthMember authMember = AuthMember.builder() - .id(member.getId()) - .name("member") - .build(); - - // when - issueService.deleteIssue(project1.getKey(), epic2P1.getId(), authMember); - em.flush(); - em.clear(); - // then - assertThat(em.find(Epic.class, epic2P1.getId())).isNull(); - assertThat(em.find(Story.class, story1P1.getId())).isNull(); - assertThat(em.find(Story.class, story2P1.getId())).isNull(); - } - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .status(IssueStatus.DO) - .project(project) - .build(); - } - - private Story createStory(String title, String content, Project project, Epic epic) { - return Story.builder() - .title(title) - .content(content) - .epic(epic) - .status(IssueStatus.DO) - .project(project) - .build(); - } - - private Task createTask(String title, String content, Project project) { - return Task.builder() - .title(title) - .content(content) - .status(IssueStatus.DO) - .project(project) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/project/controller/ProjectControllerTest.java b/src/test/java/dynamicquad/agilehub/project/controller/ProjectControllerTest.java index be71b4a..a5c9549 100644 --- a/src/test/java/dynamicquad/agilehub/project/controller/ProjectControllerTest.java +++ b/src/test/java/dynamicquad/agilehub/project/controller/ProjectControllerTest.java @@ -14,7 +14,6 @@ import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService; import dynamicquad.agilehub.global.auth.service.RefreshTokenRedisService; import dynamicquad.agilehub.global.auth.util.JwtUtil; -import dynamicquad.agilehub.global.auth.util.MemberArgumentResolver; import dynamicquad.agilehub.global.config.SpringSecurityConfig; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; @@ -42,7 +41,7 @@ @ActiveProfiles("test") @WebMvcTest(controllers = ProjectController.class) -@Import({SpringSecurityConfig.class, MemberArgumentResolver.class}) +@Import({SpringSecurityConfig.class}) class ProjectControllerTest { @Autowired diff --git a/src/test/java/dynamicquad/agilehub/project/service/ProjectServiceTest.java b/src/test/java/dynamicquad/agilehub/project/service/ProjectServiceTest.java index b1a0ea1..4efb380 100644 --- a/src/test/java/dynamicquad/agilehub/project/service/ProjectServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/project/service/ProjectServiceTest.java @@ -58,24 +58,6 @@ class ProjectServiceTest { .isEqualTo("PK1"); } - @Test - @Transactional - void 중복된_키를_가진_프로젝트를_등록하면_에러발생() { - // given - ProjectCreateRequest request = ProjectCreateRequest.builder() - .name("프로젝트1") - .key("PK1") - .build(); - AuthMember authMember = AuthMember.builder() - .id(1L) - .name("test") - .build(); - // then - assertThatThrownBy(() -> projectService.createProject(request, authMember)) - .isInstanceOf(RuntimeException.class); - } - - @Test @Transactional void 기존키가_존재하지않은키일때_예외를_반환한다() { diff --git a/src/test/java/dynamicquad/agilehub/sprint/controller/SprintControllerTest.java b/src/test/java/dynamicquad/agilehub/sprint/controller/SprintControllerTest.java index 6c70aea..22683cf 100644 --- a/src/test/java/dynamicquad/agilehub/sprint/controller/SprintControllerTest.java +++ b/src/test/java/dynamicquad/agilehub/sprint/controller/SprintControllerTest.java @@ -12,7 +12,6 @@ import dynamicquad.agilehub.global.auth.service.CustomOAuth2UserService; import dynamicquad.agilehub.global.auth.service.RefreshTokenRedisService; import dynamicquad.agilehub.global.auth.util.JwtUtil; -import dynamicquad.agilehub.global.auth.util.MemberArgumentResolver; import dynamicquad.agilehub.global.config.SpringSecurityConfig; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; @@ -39,7 +38,7 @@ @ActiveProfiles("test") @WebMvcTest(SprintController.class) -@Import({SpringSecurityConfig.class, MemberArgumentResolver.class}) +@Import({SpringSecurityConfig.class}) class SprintControllerTest { @Autowired @@ -95,8 +94,8 @@ public void setUp() { .build(); AuthMember authMember = AuthMember.builder() .id(1L) - .name("testUser") - .profileImageUrl("profile") + .name("User1") + .profileImageUrl("adada") .build(); String key = "P1"; From eca6bdbf0b9760f5426760a547002eb1100c4eb6 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Wed, 25 Dec 2024 16:26:16 +0900 Subject: [PATCH 39/45] =?UTF-8?q?feat:=20test=20=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend_ci.yml | 4 ++ .../agilehub/email/AmazonSESServiceTest.java | 41 ++++++++----------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/.github/workflows/backend_ci.yml b/.github/workflows/backend_ci.yml index c30eadc..62c1f75 100644 --- a/.github/workflows/backend_ci.yml +++ b/.github/workflows/backend_ci.yml @@ -25,6 +25,10 @@ jobs: MAIL_USERNAME: 1234 MAIL_PASSWORD: 1234 OPEN_API_KEY: 1234 + AWS_SES_ACCESS_KEY: 1234 + AWS_SES_SECRET_KEY: 1234 + AWS_SES_FROM: no-reply@agilehub.info + # Docker 데몬 연결을 위한 환경변수 추가 DOCKER_HOST: tcp://localhost:2375 TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock diff --git a/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java b/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java index b9ffaa6..d86cf07 100644 --- a/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/email/AmazonSESServiceTest.java @@ -1,26 +1,19 @@ package dynamicquad.agilehub.email; -import java.util.Map; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -class AmazonSESServiceTest { - @Autowired - private AmazonSESService amazonSESService; - - @Test - @DisplayName("Amazon SES를 이용한 이메일 발송 테스트") - void sendEmailTest() { - // given - String subject = "테스트 이메일 발송"; - String to = "evobusiness99@gmail.com"; - Map variables = Map.of("projectName", "테스트 프로젝트", "inviteCode", "123456"); - - amazonSESService.sendEmail(subject, variables, to); - } -} \ No newline at end of file +//@SpringBootTest +//@ActiveProfiles("test") +//class AmazonSESServiceTest { +// @Autowired +// private AmazonSESService amazonSESService; +// +// @Test +// @DisplayName("Amazon SES를 이용한 이메일 발송 테스트") +// void sendEmailTest() { +// // given +// String subject = "테스트 이메일 발송"; +// String to = "evobusiness99@gmail.com"; +// Map variables = Map.of("projectName", "테스트 프로젝트", "inviteCode", "123456"); +// +// amazonSESService.sendEmail(subject, variables, to); +// } +//} \ No newline at end of file From 8033d55720e9919060478193c23489fe5bfac8b5 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Wed, 25 Dec 2024 16:41:11 +0900 Subject: [PATCH 40/45] =?UTF-8?q?feat:=20test=20=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agilehub/issue/comment/CommentServiceTest.java | 5 ++++- .../agilehub/issue/service/factory/EpicFactoryTest.java | 3 +++ .../agilehub/issue/service/factory/StoryFactoryTest.java | 5 ++++- .../agilehub/issue/service/factory/TaskFactoryTest.java | 3 +++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java index d3ecf39..cc35139 100644 --- a/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java @@ -5,8 +5,9 @@ import dynamicquad.agilehub.comment.domain.Comment; import dynamicquad.agilehub.comment.response.CommentResponse.CommentCreateResponse; import dynamicquad.agilehub.comment.service.CommentService; -import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.config.RedisTestContainer; import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import dynamicquad.agilehub.project.domain.MemberProject; @@ -17,11 +18,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @ActiveProfiles("test") @SpringBootTest +@Import(RedisTestContainer.class) class CommentServiceTest { @PersistenceContext diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java index 6cf7fff..c2447db 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dynamicquad.agilehub.config.RedisTestContainer; import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; @@ -22,11 +23,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @ActiveProfiles("test") @SpringBootTest +@Import(RedisTestContainer.class) class EpicFactoryTest { @PersistenceContext diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java index 80bf35f..ce08476 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java @@ -3,11 +3,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dynamicquad.agilehub.config.RedisTestContainer; import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.IssueStatus; import dynamicquad.agilehub.issue.domain.Story; import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.issue.dto.IssueRequestDto; @@ -19,11 +20,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @ActiveProfiles("test") @SpringBootTest +@Import(RedisTestContainer.class) class StoryFactoryTest { @PersistenceContext diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java index e5f74fd..bf10ea4 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import dynamicquad.agilehub.config.RedisTestContainer; import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; @@ -19,11 +20,13 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @ActiveProfiles("test") @SpringBootTest +@Import(RedisTestContainer.class) class TaskFactoryTest { @PersistenceContext From a8a0407cd4edda75c0a85a3cd8c305b4104c1fa1 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Wed, 25 Dec 2024 16:47:31 +0900 Subject: [PATCH 41/45] =?UTF-8?q?feat:=20test=20=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/backend_ci.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/backend_ci.yml b/.github/workflows/backend_ci.yml index 62c1f75..c42a3bf 100644 --- a/.github/workflows/backend_ci.yml +++ b/.github/workflows/backend_ci.yml @@ -12,13 +12,7 @@ jobs: runs-on: ubuntu-latest - # Docker 사용을 위한 서비스 컨테이너 추가 - services: - docker: - image: docker:dind - options: --privileged - ports: - - 2375:2375 + env: JWT_SECRET: ${{ secrets.JWT_SECRET }} REDIS_HOST: localhost @@ -29,11 +23,13 @@ jobs: AWS_SES_SECRET_KEY: 1234 AWS_SES_FROM: no-reply@agilehub.info - # Docker 데몬 연결을 위한 환경변수 추가 - DOCKER_HOST: tcp://localhost:2375 - TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE: /var/run/docker.sock steps: + - name: redis 서버 실행 + uses: supercharge/redis-github-action@1.1.0 + with: + redis-version: '6.2.5' + redis-port: 6379 - name: 레포지토리를 가져옵니다 uses: actions/checkout@v3 @@ -43,6 +39,7 @@ jobs: java-version: '17' distribution: 'temurin' + - name: 그래들 캐시 uses: actions/cache@v2 with: From 406cf04f74dd7010fd9eabbb7af5ae909a83ec21 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 13 Feb 2025 14:50:56 +0900 Subject: [PATCH 42/45] =?UTF-8?q?refactor:=20gmail=20smtp=20retry=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/mail/service/EmailService.java | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java index 4f89671..be3f79a 100644 --- a/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java +++ b/src/main/java/dynamicquad/agilehub/global/mail/service/EmailService.java @@ -3,18 +3,17 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.global.mail.model.EmailInfo; -import dynamicquad.agilehub.issue.aspect.Retry; import dynamicquad.agilehub.project.model.InviteEmailInfo; import dynamicquad.agilehub.report.SummaryEmailInfo; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; -import lombok.RequiredArgsConstructor; - import java.util.concurrent.CompletableFuture; - -import org.springframework.mail.MailSendException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -23,22 +22,21 @@ @Service @RequiredArgsConstructor +@Slf4j public class EmailService { private final JavaMailSender mailSender; private final SpringTemplateEngine templateEngine; @Async("emailExecutor") - @Retry(maxRetries = 3, // 최대 3번 시도 - retryFor = { MessagingException.class, MailSendException.class }, // 이메일 관련 예외만 재시도 - delay = 1000 // 1초 대기 후 재시도 - ) + @Retryable(retryFor = {GeneralException.class}, maxAttempts = 3, backoff = @Backoff(delay = 500)) public CompletableFuture sendMail(EmailInfo emailInfo, String type) { return CompletableFuture.runAsync(() -> { try { MimeMessage message = createMimeMessage(emailInfo, type); mailSender.send(message); - } catch (MessagingException me) { + } catch (Exception e) { + log.error("Gmail SMTP 이메일 발송 실패: {}", e.getMessage()); throw new GeneralException(ErrorStatus.EMAIL_NOT_SENT); } }); @@ -57,8 +55,8 @@ private MimeMessage createMimeMessage(EmailInfo emailInfo, String type) throws M private String getEmailContent(EmailInfo emailInfo, String type) { return StringUtils.hasText(type) - ? setContext(emailInfo, type) - : emailInfo.getMessage(); + ? setContext(emailInfo, type) + : emailInfo.getMessage(); } private String setContext(EmailInfo emailInfo, String type) { @@ -66,7 +64,8 @@ private String setContext(EmailInfo emailInfo, String type) { if (type.equals("invite") && emailInfo instanceof InviteEmailInfo inviteEmailMessage) { context.setVariable("projectName", inviteEmailMessage.getProjectName()); context.setVariable("inviteCode", inviteEmailMessage.getInviteCode()); - } else if (type.equals("reportSummary") && emailInfo instanceof SummaryEmailInfo summaryEmailMessage) { + } + else if (type.equals("reportSummary") && emailInfo instanceof SummaryEmailInfo summaryEmailMessage) { context.setVariable("projectName", summaryEmailMessage.getProjectName()); context.setVariable("report", summaryEmailMessage.getReport()); } From 714b7e51f455abc30ece43670a7a7daf355b9119 Mon Sep 17 00:00:00 2001 From: minsang-alt Date: Thu, 13 Feb 2025 15:01:44 +0900 Subject: [PATCH 43/45] =?UTF-8?q?refactor:=20repository=20=EB=AA=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/dynamicquad/agilehub/dummy/DummyDataLoader.java | 8 ++++---- .../repository}/IssueBulkRepository.java | 4 ++-- .../repository}/MemberBulkRepository.java | 2 +- .../repository}/MemberProjectBulkRepository.java | 2 +- .../repository}/ProjectBulkRepository.java | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/main/java/dynamicquad/agilehub/dummy/{bulkRepository => bulk/repository}/IssueBulkRepository.java (98%) rename src/main/java/dynamicquad/agilehub/dummy/{bulkRepository => bulk/repository}/MemberBulkRepository.java (95%) rename src/main/java/dynamicquad/agilehub/dummy/{bulkRepository => bulk/repository}/MemberProjectBulkRepository.java (95%) rename src/main/java/dynamicquad/agilehub/dummy/{bulkRepository => bulk/repository}/ProjectBulkRepository.java (95%) diff --git a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java index 3a867a4..de3fba2 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java +++ b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java @@ -1,9 +1,9 @@ package dynamicquad.agilehub.dummy; -import dynamicquad.agilehub.dummy.bulkRepository.IssueBulkRepository; -import dynamicquad.agilehub.dummy.bulkRepository.MemberBulkRepository; -import dynamicquad.agilehub.dummy.bulkRepository.MemberProjectBulkRepository; -import dynamicquad.agilehub.dummy.bulkRepository.ProjectBulkRepository; +import dynamicquad.agilehub.dummy.bulk.repository.IssueBulkRepository; +import dynamicquad.agilehub.dummy.bulk.repository.MemberBulkRepository; +import dynamicquad.agilehub.dummy.bulk.repository.MemberProjectBulkRepository; +import dynamicquad.agilehub.dummy.bulk.repository.ProjectBulkRepository; import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.issue.domain.IssueStatus; diff --git a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/IssueBulkRepository.java b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java similarity index 98% rename from src/main/java/dynamicquad/agilehub/dummy/bulkRepository/IssueBulkRepository.java rename to src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java index 9cfef1e..4204a0c 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/IssueBulkRepository.java +++ b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java @@ -1,7 +1,7 @@ -package dynamicquad.agilehub.dummy.bulkRepository; +package dynamicquad.agilehub.dummy.bulk.repository; -import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.issue.domain.Story; import dynamicquad.agilehub.issue.domain.Task; import java.sql.PreparedStatement; diff --git a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberBulkRepository.java b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberBulkRepository.java similarity index 95% rename from src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberBulkRepository.java rename to src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberBulkRepository.java index 5dccbef..9857bf9 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberBulkRepository.java +++ b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberBulkRepository.java @@ -1,4 +1,4 @@ -package dynamicquad.agilehub.dummy.bulkRepository; +package dynamicquad.agilehub.dummy.bulk.repository; import dynamicquad.agilehub.member.domain.Member; import java.sql.PreparedStatement; diff --git a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberProjectBulkRepository.java b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberProjectBulkRepository.java similarity index 95% rename from src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberProjectBulkRepository.java rename to src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberProjectBulkRepository.java index edf7aa1..e842ccc 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/MemberProjectBulkRepository.java +++ b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/MemberProjectBulkRepository.java @@ -1,4 +1,4 @@ -package dynamicquad.agilehub.dummy.bulkRepository; +package dynamicquad.agilehub.dummy.bulk.repository; import dynamicquad.agilehub.project.domain.MemberProject; import jakarta.transaction.Transactional; diff --git a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/ProjectBulkRepository.java b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/ProjectBulkRepository.java similarity index 95% rename from src/main/java/dynamicquad/agilehub/dummy/bulkRepository/ProjectBulkRepository.java rename to src/main/java/dynamicquad/agilehub/dummy/bulk/repository/ProjectBulkRepository.java index 4a4a917..67710b4 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/bulkRepository/ProjectBulkRepository.java +++ b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/ProjectBulkRepository.java @@ -1,4 +1,4 @@ -package dynamicquad.agilehub.dummy.bulkRepository; +package dynamicquad.agilehub.dummy.bulk.repository; import dynamicquad.agilehub.project.domain.Project; import java.sql.PreparedStatement; From 2ff31d0a1344193c1acb07b49818247e19c800df Mon Sep 17 00:00:00 2001 From: Min Sang Date: Tue, 18 Feb 2025 17:26:29 +0900 Subject: [PATCH 44/45] =?UTF-8?q?Issue=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=20(#147)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: gitignore에 /mysql, /redis, .env 추가 * test: 테스트때 .env 주입 인텔리제이에서 환경변수 수동 주입이 아닌 .env파일 주입해서 환경변수 자동주입되도록 수정 * test: 테스트컨테이너 로컬에서 동작하지않은 오류 수정 * test: EpicFactory epic 생성 테스트 추가 --- .gitignore | 6 + build.gradle | 3 + .../agilehub/issue/aspect/Retry.java | 16 - .../agilehub/issue/aspect/RetryAspect.java | 54 --- .../issue/service/factory/EpicFactory.java | 1 - src/main/resources/application-mail.yml | 4 +- src/main/resources/application-test.yml | 19 +- src/main/resources/application.yml | 3 +- .../agilehub/config/RedisTestContainer.java | 43 ++- .../service/factory/EpicFactoryTest.java | 328 ++++-------------- 10 files changed, 117 insertions(+), 360 deletions(-) delete mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java diff --git a/.gitignore b/.gitignore index c2065bc..e9cbd16 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ out/ ### VS Code ### .vscode/ + +/mysql/ + +/redis/ + +src/main/resources/.env \ No newline at end of file diff --git a/build.gradle b/build.gradle index d3cd517..39a87d3 100644 --- a/build.gradle +++ b/build.gradle @@ -81,6 +81,9 @@ dependencies { // actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + + // .env 로드 + implementation 'io.github.cdimascio:dotenv-java:3.0.0' } tasks.named('test') { diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java b/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java deleted file mode 100644 index 322dcfb..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/Retry.java +++ /dev/null @@ -1,16 +0,0 @@ -package dynamicquad.agilehub.issue.aspect; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface Retry { - int maxRetries() default 5; - - long delay() default 100; - - Class[] retryFor() default Exception.class; -} diff --git a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java b/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java deleted file mode 100644 index a2a5e9c..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/aspect/RetryAspect.java +++ /dev/null @@ -1,54 +0,0 @@ -package dynamicquad.agilehub.issue.aspect; - -import java.util.Arrays; -import lombok.extern.slf4j.Slf4j; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.aspectj.lang.reflect.MethodSignature; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Component; - -@Aspect -@Order(Ordered.LOWEST_PRECEDENCE) -@Component -@Slf4j -public class RetryAspect { - - @Around("@annotation(dynamicquad.agilehub.issue.aspect.Retry)") - public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { - // Retry 어노테이션 정보 가져오기 - MethodSignature signature = (MethodSignature) joinPoint.getSignature(); - Retry retry = signature.getMethod().getAnnotation(Retry.class); - - int attempts = 0; - int maxAttempts = retry.maxRetries(); - - while (attempts < maxAttempts) { - try { - log.error("시도 #{}", attempts + 1); - return joinPoint.proceed(); - } catch (Exception e) { - attempts++; - - // 설정된 예외 타입들 중 하나와 일치하는지 확인 - boolean shouldRetry = Arrays.stream(retry.retryFor()) - .anyMatch(exceptionType -> exceptionType.isInstance(e)); - - if (!shouldRetry) { - throw e; // 재시도 대상이 아닌 예외는 즉시 던짐 - } - - if (attempts == maxAttempts) { - log.error("{}회 시도 후 실패", maxAttempts, e); - throw e; - } - - log.error("재시도 {}/{}", attempts, maxAttempts); - Thread.sleep(retry.delay()); - } - } - throw new RuntimeException("예기치 않은 재시도 실패"); - } -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java index d176c8e..2c26623 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java @@ -23,7 +23,6 @@ import org.springframework.transaction.annotation.Transactional; @Component("EPIC_FACTORY") -@Transactional(readOnly = true) @RequiredArgsConstructor @Slf4j public class EpicFactory implements IssueFactory { diff --git a/src/main/resources/application-mail.yml b/src/main/resources/application-mail.yml index aa3c496..91c1a6a 100644 --- a/src/main/resources/application-mail.yml +++ b/src/main/resources/application-mail.yml @@ -2,8 +2,8 @@ spring: mail: host: smtp.gmail.com port: 587 - username: ${mail.username} - password: ${mail.password} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} properties: mail: smtp: diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 8fb04ee..c4b53c3 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -3,21 +3,15 @@ spring: config: activate: on-profile: test + import: + - optional:classpath:.env[.properties] + + h2: console: enabled: true path: /h2 - datasource: - url: jdbc:h2:mem:agileHub;Mode=MySQL;DB_CLOSE_DELAY=-1; - username: sa - password: - driver-class-name: org.h2.Driver - hikari: - maximum-pool-size: 100 - minimum-idle: 5 - idle-timeout: 300000 - max-lifetime: 1800000 - connection-timeout: 30000 + jpa: hibernate: ddl-auto: create @@ -29,11 +23,12 @@ spring: default_batch_fetch_size: 100 open-in-view: false - database-platform: org.hibernate.dialect.H2Dialect + sql: init: mode: never + logging: level: org.hibernate.SQL: debug diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1d84e14..63c5bb6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,9 +16,10 @@ spring: minimum-idle: 20 # 시간 관련 설정 - max-lifetime: 50000 # 50초, DB wait_timeout보다 작게 + max-lifetime: 500000 # 500초, DB wait_timeout보다 작게 connection-timeout: 5000 # 5초, 빠른 응답이 필요한 API validation-timeout: 1000 # 1초, connection-timeout보다 작게 + idle-timeout: 100000 # 톰켓 설정 server: diff --git a/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java b/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java index 1913654..bf34720 100644 --- a/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java +++ b/src/test/java/dynamicquad/agilehub/config/RedisTestContainer.java @@ -1,33 +1,44 @@ package dynamicquad.agilehub.config; import java.time.Duration; -import org.junit.jupiter.api.DisplayName; -import org.springframework.context.annotation.Configuration; -import org.springframework.test.context.ActiveProfiles; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.GenericContainer; import org.testcontainers.utility.DockerImageName; -@DisplayName("Redis Test Container") -@ActiveProfiles("test") -@Configuration -public class RedisTestContainer { +public class RedisTestContainer implements BeforeAllCallback, AfterAllCallback { private static final String REDIS_DOCKER_IMAGE = "redis:7.0"; + private static final GenericContainer REDIS_CONTAINER; static { - try { - GenericContainer REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE)) - .withExposedPorts(6379) - .withReuse(true) - .withStartupTimeout(Duration.ofSeconds(60)); // 60초로 설정 + REDIS_CONTAINER = new GenericContainer<>(DockerImageName.parse(REDIS_DOCKER_IMAGE)) + .withExposedPorts(6379) + .withReuse(true) + .withStartupTimeout(Duration.ofSeconds(60)); + } + @Override + public void beforeAll(ExtensionContext context) throws Exception { + try { REDIS_CONTAINER.start(); - - System.setProperty("spring.redis.host", REDIS_CONTAINER.getHost()); - System.setProperty("spring.redis.port", REDIS_CONTAINER.getMappedPort(6379).toString()); } catch (Exception e) { throw new RuntimeException("Redis Container 시작 실패", e); } } + @Override + public void afterAll(ExtensionContext context) throws Exception { + if (REDIS_CONTAINER.isRunning()) { + REDIS_CONTAINER.stop(); + } + } -} \ No newline at end of file + @DynamicPropertySource + static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.redis.port", () -> REDIS_CONTAINER.getMappedPort(6379).toString()); + } +} diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java index c2447db..caa293f 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java @@ -1,303 +1,115 @@ package dynamicquad.agilehub.issue.service.factory; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import dynamicquad.agilehub.config.RedisTestContainer; -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Image; -import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueLabel; import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto.ContentDto; +import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.member.repository.MemberRepository; import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRepository; import dynamicquad.agilehub.project.domain.MemberProjectRole; import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; +import dynamicquad.agilehub.project.domain.ProjectRepository; import java.time.LocalDate; +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.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; @ActiveProfiles("test") @SpringBootTest -@Import(RedisTestContainer.class) +@Transactional +@ExtendWith(RedisTestContainer.class) class EpicFactoryTest { - @PersistenceContext - EntityManager em; - @Autowired private EpicFactory epicFactory; + @Autowired + private IssueRepository issueRepository; - @Test - @Transactional - void 이미지없는_에픽이슈를_정상적으로_생성() { - Project project1 = createProject("프로젝트1", "project1231231"); - em.persist(project1); - Project project2 = createProject("프로젝트2", "project2"); - em.persist(project2); - Member member1 = createMember("멤버1"); - em.persist(member1); - Member member2 = createMember("멤버2"); - em.persist(member2); - - MemberProject memberProject1 = MemberProject.builder() - .member(member1) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject1); - - MemberProject memberProject2 = MemberProject.builder() - .member(member2) - .project(project2) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject2); - - IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue.builder() - .title("이슈 제목") - .type(IssueType.EPIC) - .status(IssueStatus.DO) - .content("content 내용") - .files(null) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) - .assigneeId(member1.getId()) - .build(); - - //when - Long issueId = epicFactory.createIssue(request, project1); - - Epic epic = em.createQuery("select e from Epic e where e.id = :issueId", Epic.class) - .setParameter("issueId", issueId) - .getSingleResult(); - - //then - assertThat(epic.getTitle()).isEqualTo("이슈 제목"); - assertThat(epic.getContent()).isEqualTo("content 내용"); - assertThat(epic.getStatus()).isEqualTo(IssueStatus.DO); - assertThat(epic.getStartDate()).isEqualTo(LocalDate.of(2024, 2, 19)); - assertThat(epic.getEndDate()).isEqualTo(LocalDate.of(2024, 2, 23)); - assertThat(epic.getAssignee().getId()).isEqualTo(member1.getId()); - assertThat(epic.getProject().getId()).isEqualTo(project1.getId()); - - - } - - @Test - @Transactional - void 이미지없는_에픽이슈에_넣은_assinee가_서비스에_존재하지않을때_예외처리() { - //given - Project project1 = createProject("프로젝트1", "project12312353561"); - em.persist(project1); - Project project2 = createProject("프로젝트2", "project2"); - em.persist(project2); - - IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue.builder() - .title("이슈 제목") - .type(IssueType.EPIC) - .status(IssueStatus.DO) - .content("content 내용") - .files(null) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) - .assigneeId(10L) - .build(); - - //when - //then - assertThatThrownBy(() -> epicFactory.createIssue(request, project1) - ).isInstanceOf(GeneralException.class).extracting("status").isEqualTo(ErrorStatus.MEMBER_NOT_FOUND); - - } - - @Test - @Transactional - void 이미지없는_에픽이슈에_넣은_assignee가_프로젝트에_속하지않을때_예외처리() { - //given - Project project1 = createProject("프로젝트1", "project11124"); - em.persist(project1); - Project project2 = createProject("프로젝트2", "project2"); - em.persist(project2); - - Member member1 = createMember("멤버1"); - em.persist(member1); - - MemberProject memberProject1 = MemberProject.builder() - .member(member1) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject1); - - IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue.builder() - .title("이슈 제목") - .type(IssueType.EPIC) - .status(IssueStatus.DO) - .content("content 내용") - .files(null) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) - .assigneeId(member1.getId()) - .build(); - - //when - //then - assertThatThrownBy(() -> epicFactory.createIssue(request, project2) - ).isInstanceOf(GeneralException.class).extracting("status").isEqualTo(ErrorStatus.MEMBER_NOT_IN_PROJECT); + @Autowired + private ProjectRepository projectRepository; - } + @Autowired + private MemberRepository memberRepository; + @Autowired + private MemberProjectRepository memberProjectRepository; - @Test - @Transactional - void 이슈가_가지고있는_이미지들_ContentDto로_반환() { - //given - Project project1 = createProject("프로젝트1", "project152626"); - em.persist(project1); + private Project project; + private Member member; + private MemberProject memberProject; - Epic epic = Epic.builder() - .title("이슈 제목") - .content("content 내용") - .number("") - .status(IssueStatus.DO) - .assignee(null) - .project(project1) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) + @BeforeEach + void setUp() { + project = Project.builder() + .key("PT") + .name("PROJECT_TEST") .build(); - em.persist(epic); - Image image1 = Image.builder() - .path("https://file.jpg") - .build(); - image1.setIssue(epic); - em.persist(image1); + projectRepository.save(project); - Image image2 = Image.builder() - .path("https://file2.jpg") + member = Member.builder() + .name("Test Member") + .status(MemberStatus.ACTIVE) + .profileImageUrl("www.test.com") .build(); - image2.setIssue(epic); - em.persist(image2); - - //when - IssueResponseDto.ContentDto contentDto = epicFactory.createContentDto(epic); - - //then - assertThat(contentDto.getText()).isEqualTo("content 내용"); - assertThat(contentDto.getImagesURLs()).containsExactly("https://file.jpg", "https://file2.jpg"); - } - @Test - @Transactional - void 이슈가_가지고있는_이미지가_없어도_빈_ContentDto로_반환() { - //given - Project project1 = createProject("프로젝트1", "project1561"); - em.persist(project1); + memberRepository.save(member); - Epic epic = Epic.builder() - .title("이슈 제목") - .content("content 내용") - .number("") - .status(IssueStatus.DO) - .assignee(null) - .project(project1) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) + memberProject = MemberProject.builder() + .member(member) + .project(project) + .role(MemberProjectRole.ADMIN) .build(); - em.persist(epic); - - //when - ContentDto contentDto = epicFactory.createContentDto(epic); - - //then - assertThat(contentDto.getText()).isEqualTo("content 내용"); - assertThat(contentDto.getImagesURLs()).isEmpty(); + memberProjectRepository.save(memberProject); } @Test - @Transactional - void 이슈의_담당자가_변경되었을때_정상적으로_반영() { - //given - Project project1 = createProject("프로젝트1", "project1561"); - em.persist(project1); - - Member member1 = createMember("멤버1"); - em.persist(member1); - - Member member2 = createMember("멤버2"); - em.persist(member2); - - MemberProject memberProject1 = MemberProject.builder() - .member(member1) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - - em.persist(memberProject1); - - MemberProject memberProject2 = MemberProject.builder() - .member(member2) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject2); - - Epic epic = Epic.builder() - .title("이슈 제목") - .content("content 내용") - .number("") - .status(IssueStatus.DO) - .assignee(member1) - .project(project1) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) - .build(); - em.persist(epic); - - IssueRequestDto.EditIssue request = IssueRequestDto.EditIssue.builder() - .title("이슈 제목") - .status(IssueStatus.DO) - .content("content 내용") + @DisplayName("이미지가 없는 Epic 이슈를 생성한다") + void createEpicIssueWithoutImages() { + // given + IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue + .builder() + .title("테스트 에픽 이슈") + .label(IssueLabel.TEST) .files(null) - .startDate(LocalDate.of(2024, 2, 19)) - .endDate(LocalDate.of(2024, 2, 23)) - .assigneeId(member2.getId()) - .build(); - - //when - Long issueId = epicFactory.updateIssue(epic, project1, request); - - Epic updatedEpic = em.createQuery("select e from Epic e where e.id = :issueId", Epic.class) - .setParameter("issueId", issueId) - .getSingleResult(); - - //then - assertThat(updatedEpic.getAssignee().getId()).isEqualTo(member2.getId()); + .startDate(LocalDate.of(2025, 2, 10)) + .endDate(LocalDate.of(2025, 2, 20)) + .parentId(null) + .assigneeId(member.getId()) + .content("Test Epic Content") + .build(); + + // when + Long epicId = epicFactory.createIssue(request, project); + + // then + Issue savedEpic = issueRepository.findById(epicId) + .orElseThrow(() -> new RuntimeException("Epic not found")); + + Assertions.assertAll( + () -> assertThat(savedEpic.getTitle()).isEqualTo("테스트 에픽 이슈"), + () -> assertThat(savedEpic.getContent()).isEqualTo("Test Epic Content"), + () -> assertThat(((Epic) savedEpic).getStartDate()).isEqualTo(LocalDate.of(2025, 2, 10)), + () -> assertThat(((Epic) savedEpic).getEndDate()).isEqualTo(LocalDate.of(2025, 2, 20)), + () -> assertThat(savedEpic.getAssignee().getId()).isEqualTo(member.getId()), + () -> assertThat(savedEpic.getProject().getId()).isEqualTo(project.getId()), + () -> assertThat(savedEpic.getNumber()).startsWith(project.getKey() + "-") + ); } - private Member createMember(String memberName) { - return Member.builder() - .name(memberName) - .build(); - } - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } } \ No newline at end of file From 678bc8bf48cee432ef53104ebc759797bec32e6f Mon Sep 17 00:00:00 2001 From: Min Sang Date: Thu, 6 Mar 2025 01:42:29 +0900 Subject: [PATCH 45/45] =?UTF-8?q?DB=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0:=20=EC=A1=B0=EC=9D=B8=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EC=A0=84=EB=9E=B5=EC=97=90=EC=84=9C=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A0=84=EB=9E=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Issue 클래스 통합 후 Epic,Story,Task 엔티티클래스 제거 * refactor: IssueQueryService, IssueFactoryImpl Issue 도메인으로 통합 * refactor: Issue 통합 후 Sprint 에러 해결 * test(issue): jaccoo으로 커버리지 확인 및 이슈팩토리클래스와 레포지토리 쪽 테스트 테스트 용이하게 하기 위해 @sql 사용하여 테스트컨테이너에 필요한 데이터 미리 로드, 테스트컨테이너가 필요없는 곳은 테스트팩토리클래스 사용 * test(issue): 바로 앞 커밋에 안넣어진 파일들 추가 * test(issue): issueValidator 테스트 작성 * test(issue): IssueQueryService 및 issueFactoryImpl 테스트 * test(issue): IssueService 테스트 --- build.gradle | 26 +- gradlew | 0 .../agilehub/dummy/DummyDataLoader.java | 362 +++++++++--------- .../bulk/repository/IssueBulkRepository.java | 228 +++++------ .../agilehub/issue/domain/Epic.java | 57 --- .../agilehub/issue/domain/Issue.java | 86 +++-- .../agilehub/issue/domain/Story.java | 79 ---- .../agilehub/issue/domain/Task.java | 71 ---- .../agilehub/issue/dto/IssueResponseDto.java | 38 +- .../issue/dto/backlog/EpicResponseDto.java | 7 +- .../issue/dto/backlog/StoryResponseDto.java | 4 +- .../issue/dto/backlog/TaskResponseDto.java | 4 +- .../issue/repository/EpicRepository.java | 30 -- .../issue/repository/IssueRepository.java | 50 ++- .../issue/repository/StoryRepository.java | 21 - .../issue/repository/TaskRepository.java | 18 - .../service/command/IssueNumberGenerator.java | 11 +- .../issue/service/command/IssueService.java | 38 +- .../issue/service/factory/EpicFactory.java | 127 ------ .../service/factory/IssueFactoryImpl.java | 134 +++++++ .../service/factory/IssueFactoryProvider.java | 30 -- .../issue/service/factory/StoryFactory.java | 160 -------- .../issue/service/factory/TaskFactory.java | 138 ------- .../service/query/IssueQueryService.java | 50 ++- .../agilehub/member/dto/AssigneeDto.java | 2 + .../agilehub/project/domain/Project.java | 2 +- .../sprint/dto/SprintResponseDto.java | 42 +- src/main/resources/application-test.yml | 8 - .../agilehub/config/MySQLTestContainer.java | 47 +++ .../issue/comment/CommentServiceTest.java | 262 ++++++------- .../comment/domain/CommentRepositoryTest.java | 178 ++++----- .../agilehub/issue/domain/EpicTest.java | 100 ----- .../issue/domain/IssueRepositoryTest.java | 188 ++++++--- .../agilehub/issue/domain/IssueTest.java | 5 + .../agilehub/issue/domain/StoryTest.java | 110 ------ .../issue/domain/epic/EpicRepositoryTest.java | 107 ------ .../issue/service/ImageServiceTest.java | 166 -------- .../service/IssueUpdateConcurrencyTest.java | 158 -------- .../issue/service/IssueValidatorTest.java | 170 ++++++++ .../service/command/IssueServiceTest.java | 202 ++++++++++ .../service/factory/EpicFactoryTest.java | 115 ------ .../service/factory/IssueFactoryImplTest.java | 290 ++++++++++++++ .../service/factory/StoryFactoryTest.java | 200 ---------- .../service/factory/TaskFactoryTest.java | 132 ------- .../service/query/IssueQueryServiceTest.java | 330 +++++++++++++--- .../sprint/SprintQueryServiceTest.java | 210 +++++----- .../agilehub/testSetUp/TestDataSetup.java | 85 ++++ src/test/resources/sql/cleanup.sql | 18 + .../sql/epic-statistics-test-data.sql | 48 +++ src/test/resources/sql/test-data.sql | 26 ++ 50 files changed, 2259 insertions(+), 2711 deletions(-) mode change 100644 => 100755 gradlew delete mode 100644 src/main/java/dynamicquad/agilehub/issue/domain/Epic.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/domain/Story.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/domain/Task.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/repository/EpicRepository.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/repository/StoryRepository.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/repository/TaskRepository.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java create mode 100644 src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImpl.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryProvider.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java delete mode 100644 src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java create mode 100644 src/test/java/dynamicquad/agilehub/config/MySQLTestContainer.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/domain/IssueTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/domain/epic/EpicRepositoryTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/ImageServiceTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/service/IssueValidatorTest.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/service/command/IssueServiceTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java create mode 100644 src/test/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImplTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java delete mode 100644 src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java create mode 100644 src/test/java/dynamicquad/agilehub/testSetUp/TestDataSetup.java create mode 100644 src/test/resources/sql/cleanup.sql create mode 100644 src/test/resources/sql/epic-statistics-test-data.sql create mode 100644 src/test/resources/sql/test-data.sql diff --git a/build.gradle b/build.gradle index 39a87d3..254f374 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.2.3' id 'io.spring.dependency-management' version '1.1.4' + id 'jacoco' } group = 'dynamicquad' @@ -50,6 +51,7 @@ dependencies { // test : JUnit5 testImplementation 'org.springframework.boot:spring-boot-starter-test' + // aws s3 implementation 'com.amazonaws:aws-java-sdk-s3:1.12.676' @@ -59,6 +61,8 @@ dependencies { // test-containers testImplementation group: 'org.testcontainers', name: 'testcontainers', version: '1.17.2' + testImplementation "org.testcontainers:mysql:1.17.2" // mysql 컨테이너를 사용한다면 추가 + // QueryDSL implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' @@ -81,7 +85,7 @@ dependencies { // actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' - + // .env 로드 implementation 'io.github.cdimascio:dotenv-java:3.0.0' } @@ -89,3 +93,23 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +// JaCoCo 설정 (테스트 커버리지 측정) +jacoco { + toolVersion = "0.8.10" + reportsDirectory = layout.buildDirectory.dir('reports/jacoco') +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true // CI 툴 연동용 + html.required = true // 로컬 확인용 + } + afterEvaluate { + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, excludes: ['**/config/**', '**/dto/**', '**/response/**', '**/request/**', '**/global/**', '**/domain/**', '**/model/**']) + // 특정 패키지 제외 + })) + } +} \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java index de3fba2..1346ed7 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java +++ b/src/main/java/dynamicquad/agilehub/dummy/DummyDataLoader.java @@ -1,181 +1,181 @@ -package dynamicquad.agilehub.dummy; - -import dynamicquad.agilehub.dummy.bulk.repository.IssueBulkRepository; -import dynamicquad.agilehub.dummy.bulk.repository.MemberBulkRepository; -import dynamicquad.agilehub.dummy.bulk.repository.MemberProjectBulkRepository; -import dynamicquad.agilehub.dummy.bulk.repository.ProjectBulkRepository; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.domain.MemberStatus; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.annotation.PostConstruct; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Profile("dummy") -@Slf4j -public class DummyDataLoader { - private final ProjectBulkRepository projectBulkRepository; - private final MemberBulkRepository memberBulkRepository; - private final MemberProjectBulkRepository memberProjectBulkRepository; - private final IssueBulkRepository issueBulkRepository; - - - @PostConstruct - void bulkInsert() { - - // project 벌크 실행 - long startTime = System.currentTimeMillis(); - - projectBulk(); - memberBulk(); - memberProjectBulk(); - epicBulk(); - epicIssueBulk(); - storyBulk(); - storyIssueBulk(); - taskBulk(); - taskIssueBulk(); - - long endTime = System.currentTimeMillis(); - System.out.println("--------------------"); - System.out.println("수행시간 : " + (endTime - startTime) + "ms"); - System.out.println("--------------------"); - - } - - - private void projectBulk() { - List projects = new ArrayList<>(); - for (long i = 0; i < 10_000L; i++) { - projects.add(Project.builder() - .name("프로젝트" + i) - .key("KEY" + i) - .build()); - } - - projectBulkRepository.saveAll(projects); - } - - private void memberBulk() { - List members = new ArrayList<>(); - for (long i = 0; i < 10_000L; i++) { - members.add(Member.builder() - .name("멤버" + i) - .status(MemberStatus.ACTIVE) - .build()); - } - memberBulkRepository.saveAll(members); - } - - private void memberProjectBulk() { - // memberProject 벌크 실행 - List memberProjects = new ArrayList<>(); - for (long i = 0; i < 10_000L; i++) { - memberProjects.add(MemberProject.builder() - .role(MemberProjectRole.ADMIN) - .build()); - } - memberProjectBulkRepository.saveAll(memberProjects); - } - - private void epicBulk() { - List epics = new ArrayList<>(); - for (long i = 0; i < 100; i++) { - epics.add(Epic.builder() - .title("에픽" + i) - .content("에픽 내용" + i) - .number("") - .status(IssueStatus.DO) - .startDate(LocalDate.of(2021, 1, 1)) - .endDate(LocalDate.of(2021, 10, 23)) - .build()); - } - issueBulkRepository.saveEpicAll(epics, 1L, 1L, 1L); - } - - private void epicIssueBulk() { - // epic-issue 매핑 벌크 실행 - List epicIssueMappings = new ArrayList<>(); - for (long i = 0; i < 100; i++) { - epicIssueMappings.add(Epic.builder() - .startDate(LocalDate.of(2021, 1, 1)) - .endDate(LocalDate.of(2021, 10, 23)) - .build()); - } - issueBulkRepository.saveIssueEpicAll(epicIssueMappings); - } - - - private void storyBulk() { - List stories = new ArrayList<>(); - for (long i = 0; i < 100 * 200; i++) { - stories.add(Story.builder() - .title("스토리" + i) - .content("스토리 내용" + i) - .number("") - .status(IssueStatus.DO) - .startDate(LocalDate.of(2021, 1, 1)) - .endDate(LocalDate.of(2021, 10, 23)) - .build()); - } - issueBulkRepository.saveStoryAll(stories, 1L, 1L, 1L); - } - - private void storyIssueBulk() { - // story-issue 매핑 벌크 실행 - for (long i = 0; i < 100; i++) { - List storiesMappingIssue = new ArrayList<>(); - for (long j = 0; j < 200; j++) { - storiesMappingIssue.add(Story.builder() - .startDate(LocalDate.of(2021, 1, 1)) - .endDate(LocalDate.of(2021, 10, 23)) - .build()); - } - - issueBulkRepository.saveIssueStoryAll(storiesMappingIssue, i + 1, 101L + i * 200L); - } - } - - private void taskBulk() { - List tasks = new ArrayList<>(); - for (long i = 0; i < 100 * 200 * 200; i++) { - tasks.add(Task.builder() - .title("태스크" + i) - .content("태스크 내용" + i) - .number("") - .status(IssueStatus.DO) - .build()); - } - issueBulkRepository.saveTaskAll(tasks, 1L, 1L, 1L); - } - - private void taskIssueBulk() { - // task-issue 매핑 벌크 실행 - for (long i = 0; i < 100 * 200; i++) { - List tasksMappingIssue = new ArrayList<>(); - for (long j = 0; j < 200; j++) { - tasksMappingIssue.add(Task.builder() - .build()); - } - - issueBulkRepository.saveIssueTaskAll(tasksMappingIssue, 101L + i, 20101L + i * 200L); - } - - } - - -} +//package dynamicquad.agilehub.dummy; +// +//import dynamicquad.agilehub.dummy.bulk.repository.IssueBulkRepository; +//import dynamicquad.agilehub.dummy.bulk.repository.MemberBulkRepository; +//import dynamicquad.agilehub.dummy.bulk.repository.MemberProjectBulkRepository; +//import dynamicquad.agilehub.dummy.bulk.repository.ProjectBulkRepository; +//import dynamicquad.agilehub.issue.domain.Epic; +//import dynamicquad.agilehub.issue.domain.Issue; +//import dynamicquad.agilehub.issue.domain.IssueStatus; +//import dynamicquad.agilehub.issue.domain.Story; +//import dynamicquad.agilehub.issue.domain.Task; +//import dynamicquad.agilehub.member.domain.Member; +//import dynamicquad.agilehub.member.domain.MemberStatus; +//import dynamicquad.agilehub.project.domain.MemberProject; +//import dynamicquad.agilehub.project.domain.MemberProjectRole; +//import dynamicquad.agilehub.project.domain.Project; +//import jakarta.annotation.PostConstruct; +//import java.time.LocalDate; +//import java.util.ArrayList; +//import java.util.List; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.context.annotation.Profile; +//import org.springframework.stereotype.Component; +// +//@Component +//@RequiredArgsConstructor +//@Profile("dummy") +//@Slf4j +//public class DummyDataLoader { +// private final ProjectBulkRepository projectBulkRepository; +// private final MemberBulkRepository memberBulkRepository; +// private final MemberProjectBulkRepository memberProjectBulkRepository; +// private final IssueBulkRepository issueBulkRepository; +// +// +// @PostConstruct +// void bulkInsert() { +// +// // project 벌크 실행 +// long startTime = System.currentTimeMillis(); +// +// projectBulk(); +// memberBulk(); +// memberProjectBulk(); +// epicBulk(); +// epicIssueBulk(); +// storyBulk(); +// storyIssueBulk(); +// taskBulk(); +// taskIssueBulk(); +// +// long endTime = System.currentTimeMillis(); +// System.out.println("--------------------"); +// System.out.println("수행시간 : " + (endTime - startTime) + "ms"); +// System.out.println("--------------------"); +// +// } +// +// +// private void projectBulk() { +// List projects = new ArrayList<>(); +// for (long i = 0; i < 10_000L; i++) { +// projects.add(Project.builder() +// .name("프로젝트" + i) +// .key("KEY" + i) +// .build()); +// } +// +// projectBulkRepository.saveAll(projects); +// } +// +// private void memberBulk() { +// List members = new ArrayList<>(); +// for (long i = 0; i < 10_000L; i++) { +// members.add(Member.builder() +// .name("멤버" + i) +// .status(MemberStatus.ACTIVE) +// .build()); +// } +// memberBulkRepository.saveAll(members); +// } +// +// private void memberProjectBulk() { +// // memberProject 벌크 실행 +// List memberProjects = new ArrayList<>(); +// for (long i = 0; i < 10_000L; i++) { +// memberProjects.add(MemberProject.builder() +// .role(MemberProjectRole.ADMIN) +// .build()); +// } +// memberProjectBulkRepository.saveAll(memberProjects); +// } +// +// private void epicBulk() { +// List epics = new ArrayList<>(); +// for (long i = 0; i < 100; i++) { +// epics.add(Epic.builder() +// .title("에픽" + i) +// .content("에픽 내용" + i) +// .number("") +// .status(IssueStatus.DO) +// .startDate(LocalDate.of(2021, 1, 1)) +// .endDate(LocalDate.of(2021, 10, 23)) +// .build()); +// } +// issueBulkRepository.saveEpicAll(epics, 1L, 1L, 1L); +// } +// +// private void epicIssueBulk() { +// // epic-issue 매핑 벌크 실행 +// List epicIssueMappings = new ArrayList<>(); +// for (long i = 0; i < 100; i++) { +// epicIssueMappings.add(Epic.builder() +// .startDate(LocalDate.of(2021, 1, 1)) +// .endDate(LocalDate.of(2021, 10, 23)) +// .build()); +// } +// issueBulkRepository.saveIssueEpicAll(epicIssueMappings); +// } +// +// +// private void storyBulk() { +// List stories = new ArrayList<>(); +// for (long i = 0; i < 100 * 200; i++) { +// stories.add(Story.builder() +// .title("스토리" + i) +// .content("스토리 내용" + i) +// .number("") +// .status(IssueStatus.DO) +// .startDate(LocalDate.of(2021, 1, 1)) +// .endDate(LocalDate.of(2021, 10, 23)) +// .build()); +// } +// issueBulkRepository.saveStoryAll(stories, 1L, 1L, 1L); +// } +// +// private void storyIssueBulk() { +// // story-issue 매핑 벌크 실행 +// for (long i = 0; i < 100; i++) { +// List storiesMappingIssue = new ArrayList<>(); +// for (long j = 0; j < 200; j++) { +// storiesMappingIssue.add(Story.builder() +// .startDate(LocalDate.of(2021, 1, 1)) +// .endDate(LocalDate.of(2021, 10, 23)) +// .build()); +// } +// +// issueBulkRepository.saveIssueStoryAll(storiesMappingIssue, i + 1, 101L + i * 200L); +// } +// } +// +// private void taskBulk() { +// List tasks = new ArrayList<>(); +// for (long i = 0; i < 100 * 200 * 200; i++) { +// tasks.add(Task.builder() +// .title("태스크" + i) +// .content("태스크 내용" + i) +// .number("") +// .status(IssueStatus.DO) +// .build()); +// } +// issueBulkRepository.saveTaskAll(tasks, 1L, 1L, 1L); +// } +// +// private void taskIssueBulk() { +// // task-issue 매핑 벌크 실행 +// for (long i = 0; i < 100 * 200; i++) { +// List tasksMappingIssue = new ArrayList<>(); +// for (long j = 0; j < 200; j++) { +// tasksMappingIssue.add(Task.builder() +// .build()); +// } +// +// issueBulkRepository.saveIssueTaskAll(tasksMappingIssue, 101L + i, 20101L + i * 200L); +// } +// +// } +// +// +//} diff --git a/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java index 4204a0c..d1c87d5 100644 --- a/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java +++ b/src/main/java/dynamicquad/agilehub/dummy/bulk/repository/IssueBulkRepository.java @@ -1,114 +1,114 @@ -package dynamicquad.agilehub.dummy.bulk.repository; - -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import java.sql.PreparedStatement; -import java.util.List; -import java.util.concurrent.atomic.AtomicLong; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -@Repository -@RequiredArgsConstructor -@Slf4j -public class IssueBulkRepository { - private final JdbcTemplate jdbcTemplate; - - @Transactional - public void saveEpicAll(List issues, Long projectId, Long sprintId, Long memberId) { - String sql = "INSERT INTO issue (content, issue_type, number, project_id,status, title, member_id) " - + "VALUES (?,?,?,?,?,?,?)"; - jdbcTemplate.batchUpdate(sql, issues, issues.size(), - (PreparedStatement ps, Issue issue) -> { - ps.setString(1, issue.getContent()); - ps.setString(2, "EPIC"); - ps.setString(3, String.valueOf(issue.getNumber())); - ps.setLong(4, projectId); - ps.setString(5, String.valueOf(issue.getStatus())); - ps.setString(6, issue.getTitle()); - ps.setLong(7, memberId); - }); - } - - @Transactional - public void saveIssueEpicAll(List issues) { - String epicSql = "INSERT INTO epic (issue_id, start_date,end_date) " - + "VALUES (?, ?,?)"; - - AtomicLong index = new AtomicLong(1L); - jdbcTemplate.batchUpdate(epicSql, issues, issues.size(), - (PreparedStatement ps, Epic issue) -> { - ps.setLong(1, index.get()); - ps.setString(2, String.valueOf(issue.getStartDate())); - ps.setString(3, String.valueOf(issue.getEndDate())); - index.getAndIncrement(); - }); - } - - @Transactional - public void saveStoryAll(List issues, Long projectId, Long sprintId, Long memberId) { - String sql = "INSERT INTO issue (content, issue_type, number, project_id, status, title, member_id) " - + "VALUES (?,?,?,?, ?,?,?)"; - jdbcTemplate.batchUpdate(sql, issues, issues.size(), - (PreparedStatement ps, Issue issue) -> { - ps.setString(1, issue.getContent()); - ps.setString(2, "STORY"); - ps.setString(3, String.valueOf(issue.getNumber())); - ps.setLong(4, projectId); - ps.setString(5, String.valueOf(issue.getStatus())); - ps.setString(6, issue.getTitle()); - ps.setLong(7, memberId); - }); - } - - @Transactional - public void saveIssueStoryAll(List issues, Long epicId, Long id) { - String storySql = "INSERT INTO story (issue_id, epic_id, start_date,end_date) " - + "VALUES (?,?,?,?)"; - - AtomicLong index = new AtomicLong(id); - jdbcTemplate.batchUpdate(storySql, issues, issues.size(), - (PreparedStatement ps, Story issue) -> { - ps.setLong(1, index.get()); - ps.setLong(2, epicId); - ps.setString(3, String.valueOf(issue.getStartDate())); - ps.setString(4, String.valueOf(issue.getEndDate())); - index.getAndIncrement(); - }); - } - - @Transactional - public void saveTaskAll(List issues, Long projectId, Long sprintId, Long memberId) { - String sql = "INSERT INTO issue (content, issue_type, number, project_id, status, title, member_id) " - + "VALUES (?,?,?,?,?,?,?)"; - jdbcTemplate.batchUpdate(sql, issues, issues.size(), - (PreparedStatement ps, Issue issue) -> { - ps.setString(1, issue.getContent()); - ps.setString(2, "TASK"); - ps.setString(3, String.valueOf(issue.getNumber())); - ps.setLong(4, projectId); - ps.setString(5, String.valueOf(issue.getStatus())); - ps.setString(6, issue.getTitle()); - ps.setLong(7, memberId); - }); - } - - @Transactional - public void saveIssueTaskAll(List issues, Long storyId, Long id) { - String taskSql = "INSERT INTO task (issue_id, story_id) " - + "VALUES (?,?)"; - - AtomicLong index = new AtomicLong(id); - jdbcTemplate.batchUpdate(taskSql, issues, issues.size(), - (PreparedStatement ps, Task issue) -> { - ps.setLong(1, index.get()); - ps.setLong(2, storyId); - index.getAndIncrement(); - }); - } -} +//package dynamicquad.agilehub.dummy.bulk.repository; +// +//import dynamicquad.agilehub.issue.domain.Epic; +//import dynamicquad.agilehub.issue.domain.Issue; +//import dynamicquad.agilehub.issue.domain.Story; +//import dynamicquad.agilehub.issue.domain.Task; +//import java.sql.PreparedStatement; +//import java.util.List; +//import java.util.concurrent.atomic.AtomicLong; +//import lombok.RequiredArgsConstructor; +//import lombok.extern.slf4j.Slf4j; +//import org.springframework.jdbc.core.JdbcTemplate; +//import org.springframework.stereotype.Repository; +//import org.springframework.transaction.annotation.Transactional; +// +//@Repository +//@RequiredArgsConstructor +//@Slf4j +//public class IssueBulkRepository { +// private final JdbcTemplate jdbcTemplate; +// +// @Transactional +// public void saveEpicAll(List issues, Long projectId, Long sprintId, Long memberId) { +// String sql = "INSERT INTO issue (content, issue_type, number, project_id,status, title, member_id) " +// + "VALUES (?,?,?,?,?,?,?)"; +// jdbcTemplate.batchUpdate(sql, issues, issues.size(), +// (PreparedStatement ps, Issue issue) -> { +// ps.setString(1, issue.getContent()); +// ps.setString(2, "EPIC"); +// ps.setString(3, String.valueOf(issue.getNumber())); +// ps.setLong(4, projectId); +// ps.setString(5, String.valueOf(issue.getStatus())); +// ps.setString(6, issue.getTitle()); +// ps.setLong(7, memberId); +// }); +// } +// +// @Transactional +// public void saveIssueEpicAll(List issues) { +// String epicSql = "INSERT INTO epic (issue_id, start_date,end_date) " +// + "VALUES (?, ?,?)"; +// +// AtomicLong index = new AtomicLong(1L); +// jdbcTemplate.batchUpdate(epicSql, issues, issues.size(), +// (PreparedStatement ps, Epic issue) -> { +// ps.setLong(1, index.get()); +// ps.setString(2, String.valueOf(issue.getStartDate())); +// ps.setString(3, String.valueOf(issue.getEndDate())); +// index.getAndIncrement(); +// }); +// } +// +// @Transactional +// public void saveStoryAll(List issues, Long projectId, Long sprintId, Long memberId) { +// String sql = "INSERT INTO issue (content, issue_type, number, project_id, status, title, member_id) " +// + "VALUES (?,?,?,?, ?,?,?)"; +// jdbcTemplate.batchUpdate(sql, issues, issues.size(), +// (PreparedStatement ps, Issue issue) -> { +// ps.setString(1, issue.getContent()); +// ps.setString(2, "STORY"); +// ps.setString(3, String.valueOf(issue.getNumber())); +// ps.setLong(4, projectId); +// ps.setString(5, String.valueOf(issue.getStatus())); +// ps.setString(6, issue.getTitle()); +// ps.setLong(7, memberId); +// }); +// } +// +// @Transactional +// public void saveIssueStoryAll(List issues, Long epicId, Long id) { +// String storySql = "INSERT INTO story (issue_id, epic_id, start_date,end_date) " +// + "VALUES (?,?,?,?)"; +// +// AtomicLong index = new AtomicLong(id); +// jdbcTemplate.batchUpdate(storySql, issues, issues.size(), +// (PreparedStatement ps, Story issue) -> { +// ps.setLong(1, index.get()); +// ps.setLong(2, epicId); +// ps.setString(3, String.valueOf(issue.getStartDate())); +// ps.setString(4, String.valueOf(issue.getEndDate())); +// index.getAndIncrement(); +// }); +// } +// +// @Transactional +// public void saveTaskAll(List issues, Long projectId, Long sprintId, Long memberId) { +// String sql = "INSERT INTO issue (content, issue_type, number, project_id, status, title, member_id) " +// + "VALUES (?,?,?,?,?,?,?)"; +// jdbcTemplate.batchUpdate(sql, issues, issues.size(), +// (PreparedStatement ps, Issue issue) -> { +// ps.setString(1, issue.getContent()); +// ps.setString(2, "TASK"); +// ps.setString(3, String.valueOf(issue.getNumber())); +// ps.setLong(4, projectId); +// ps.setString(5, String.valueOf(issue.getStatus())); +// ps.setString(6, issue.getTitle()); +// ps.setLong(7, memberId); +// }); +// } +// +// @Transactional +// public void saveIssueTaskAll(List issues, Long storyId, Long id) { +// String taskSql = "INSERT INTO task (issue_id, story_id) " +// + "VALUES (?,?)"; +// +// AtomicLong index = new AtomicLong(id); +// jdbcTemplate.batchUpdate(taskSql, issues, issues.size(), +// (PreparedStatement ps, Task issue) -> { +// ps.setLong(1, index.get()); +// ps.setLong(2, storyId); +// index.getAndIncrement(); +// }); +// } +//} diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java b/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java deleted file mode 100644 index 47e0b2a..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Epic.java +++ /dev/null @@ -1,57 +0,0 @@ -package dynamicquad.agilehub.issue.domain; - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.CascadeType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.OneToMany; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@DiscriminatorValue("EPIC") -@Entity -public class Epic extends Issue { - - private LocalDate startDate; - private LocalDate endDate; - - @OneToMany(mappedBy = "epic", cascade = CascadeType.REMOVE) - private List stories = new ArrayList<>(); - - @Builder - private Epic(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, - Project project, LocalDate startDate, LocalDate endDate) { - super(title, content, number, status, label, assignee, project); - this.startDate = startDate; - this.endDate = endDate; - } - - public void updateEpic(IssueRequestDto.EditIssue request, Member assignee) { - super.updateIssue(request, assignee); - this.startDate = request.getStartDate(); - this.endDate = request.getEndDate(); - } - - public static Epic extractFromIssue(Issue issue) { - if (!(issue instanceof Epic epic)) { - throw new GeneralException(ErrorStatus.ISSUE_TYPE_NOT_FOUND); - } - return epic; - } - - public void updatePeriod(LocalDate startDate, LocalDate endDate) { - this.startDate = startDate; - this.endDate = endDate; - } -} diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java index a30143f..a5a47eb 100644 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java +++ b/src/main/java/dynamicquad/agilehub/issue/domain/Issue.java @@ -2,13 +2,14 @@ import dynamicquad.agilehub.comment.domain.Comment; import dynamicquad.agilehub.global.domain.BaseEntity; +import dynamicquad.agilehub.issue.IssueType; import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.CreateIssue; import dynamicquad.agilehub.member.domain.Member; import dynamicquad.agilehub.project.domain.Project; import dynamicquad.agilehub.sprint.domain.Sprint; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; -import jakarta.persistence.DiscriminatorColumn; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -16,42 +17,31 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.Inheritance; -import jakarta.persistence.InheritanceType; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import lombok.AccessLevel; -import lombok.EqualsAndHashCode; -import lombok.EqualsAndHashCode.Include; +import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@Inheritance(strategy = InheritanceType.JOINED) -@EqualsAndHashCode(onlyExplicitlyIncluded = true) -@DiscriminatorColumn(name = "issue_type") -@Table(name = "issue") +@Table(name = "issue_new") // 기존 테이블(issue) 대신 issue_new 사용 @Entity -public abstract class Issue extends BaseEntity { +@Getter +public class Issue extends BaseEntity { + + protected Issue() { + } @Id - @Include @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "issue_id") private Long id; -// @Version -// private Long version; - private String title; - private String content; - private String number; @Enumerated(EnumType.STRING) @@ -60,6 +50,15 @@ public abstract class Issue extends BaseEntity { @Enumerated(EnumType.STRING) private IssueLabel label; + @Enumerated(EnumType.STRING) + private IssueType issueType; // Epic, Story, Task 구분을 위한 필드 추가 + + private LocalDate startDate; // Epic, Story, Task 공통 사용 + private LocalDate endDate; + + private Integer storyPoint; // Story 전용 필드 + private Long parentIssueId; // 계층 구조 (Epic → Story → Task) 표현 + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id") private Member assignee; @@ -82,9 +81,10 @@ public void setSprint(Sprint newSprint) { this.sprint = newSprint; } - + @Builder protected Issue(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, - Project project) { + Project project, IssueType issueType, LocalDate startDate, LocalDate endDate, Integer storyPoint, + Long parentIssueId) { this.title = title; this.content = content; this.number = number; @@ -92,22 +92,58 @@ protected Issue(String title, String content, String number, IssueStatus status, this.label = label; this.assignee = assignee; this.project = project; + this.issueType = issueType; + this.startDate = startDate; + this.endDate = endDate; + this.storyPoint = storyPoint; + this.parentIssueId = parentIssueId; } - protected void updateIssue(IssueRequestDto.EditIssue request, Member assignee) { + public static Issue createEpic(CreateIssue request, Member assignee, String issueNumber, Project project) { + return new Issue(request.getTitle(), request.getContent(), issueNumber, request.getStatus(), request.getLabel(), + assignee, project, IssueType.EPIC, request.getStartDate(), request.getEndDate(), null, null); + } + + public static Issue createStory(CreateIssue request, Member assignee, String issueNumber, Project project) { + return new Issue(request.getTitle(), request.getContent(), issueNumber, request.getStatus(), request.getLabel(), + assignee, project, IssueType.STORY, request.getStartDate(), request.getEndDate(), null, + request.getParentId()); + } + + public static Issue createTask(CreateIssue request, Member assignee, String issueNumber, Project project) { + return new Issue(request.getTitle(), request.getContent(), issueNumber, request.getStatus(), request.getLabel(), + assignee, project, IssueType.TASK, request.getStartDate(), request.getEndDate(), null, + request.getParentId()); + } + + public void updateIssue(IssueRequestDto.EditIssue request, Member assignee) { this.title = request.getTitle(); this.content = request.getContent(); this.status = request.getStatus(); this.label = request.getLabel(); this.assignee = assignee; + + if (this.issueType == IssueType.EPIC || this.issueType == IssueType.STORY) { + this.startDate = request.getStartDate(); + this.endDate = request.getEndDate(); + } + } public void updateContent(String content) { this.content = content; } - public void updateStatus(IssueStatus updateStatus) { this.status = updateStatus; } -} + + public void updatePeriod(LocalDate startDate, LocalDate endDate) { + this.startDate = startDate; + this.endDate = endDate; + } + + public void setParentIssue(Issue parentIssue) { + this.parentIssueId = parentIssue.getId(); + } +} \ No newline at end of file diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java b/src/main/java/dynamicquad/agilehub/issue/domain/Story.java deleted file mode 100644 index 262dd83..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Story.java +++ /dev/null @@ -1,79 +0,0 @@ -package dynamicquad.agilehub.issue.domain; - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.CascadeType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@DiscriminatorValue("STORY") -@Entity -public class Story extends Issue { - - private Integer storyPoint; - private LocalDate startDate; - private LocalDate endDate; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "epic_id") - private Epic epic; - - @OneToMany(mappedBy = "story", cascade = CascadeType.REMOVE) - private List tasks = new ArrayList<>(); - - @Builder - private Story(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, - Project project, - int storyPoint, LocalDate startDate, LocalDate endDate, Epic epic) { - super(title, content, number, status, label, assignee, project); - this.storyPoint = storyPoint; - this.startDate = startDate; - this.endDate = endDate; - this.epic = epic; - if (epic != null) { - epic.getStories().add(this); - } - } - - public void updateStory(IssueRequestDto.EditIssue request, Member assignee, Epic upEpic) { - super.updateIssue(request, assignee); - this.startDate = request.getStartDate(); - this.endDate = request.getEndDate(); - - if (this.epic != null) { - this.epic.getStories().remove(this); - } - this.epic = upEpic; - if (upEpic != null) { - upEpic.getStories().add(this); - } - } - - public static Story extractFromIssue(Issue issue) { - if (!(issue instanceof Story story)) { - throw new GeneralException(ErrorStatus.ISSUE_TYPE_NOT_FOUND); - } - return story; - } - - public void updatePeriod(LocalDate startDate, LocalDate endDate) { - this.startDate = startDate; - this.endDate = endDate; - } -} diff --git a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java b/src/main/java/dynamicquad/agilehub/issue/domain/Task.java deleted file mode 100644 index b92a2a8..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/domain/Task.java +++ /dev/null @@ -1,71 +0,0 @@ -package dynamicquad.agilehub.issue.domain; - - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import java.time.LocalDate; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@DiscriminatorValue("TASK") -@Entity -public class Task extends Issue { - - private LocalDate startDate; - private LocalDate endDate; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "story_id") - private Story story; - - @Builder - private Task(String title, String content, String number, IssueStatus status, IssueLabel label, Member assignee, - Project project, LocalDate startDate, LocalDate endDate, Story story) { - super(title, content, number, status, label, assignee, project); - this.story = story; - this.startDate = startDate; - this.endDate = endDate; - if (story != null) { - story.getTasks().add(this); - } - } - - public void updateTask(IssueRequestDto.EditIssue request, Member assignee, Story upStory) { - super.updateIssue(request, assignee); - this.startDate = request.getStartDate(); - this.endDate = request.getEndDate(); - - if (this.story != null) { - this.story.getTasks().remove(this); - } - this.story = upStory; - if (upStory != null) { - upStory.getTasks().add(this); - } - - } - - public static Task extractFromIssue(Issue issue) { - if (!(issue instanceof Task task)) { - throw new GeneralException(ErrorStatus.ISSUE_TYPE_NOT_FOUND); - } - return task; - } - - public void updatePeriod(LocalDate startDate, LocalDate endDate) { - this.startDate = startDate; - this.endDate = endDate; - } -} diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java index fd17fdc..7d590e5 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/IssueResponseDto.java @@ -1,17 +1,15 @@ package dynamicquad.agilehub.issue.dto; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Image; import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.member.dto.AssigneeDto; import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.ToString; public class IssueResponseDto { private IssueResponseDto() { @@ -21,6 +19,7 @@ private IssueResponseDto() { @Getter @AllArgsConstructor @EqualsAndHashCode + @ToString public static class IssueAndSubIssueDetail { private IssueDetail issue; private SubIssueDetail parentIssue; @@ -41,6 +40,7 @@ public static IssueAndSubIssueDetail from(IssueDetail issueDetail, SubIssueDetai @Getter @AllArgsConstructor @EqualsAndHashCode + @ToString public static class IssueDetail { private Long issueId; private String key; @@ -53,38 +53,20 @@ public static class IssueDetail { private ContentDto content; private AssigneeDto assignee; - public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto assigneeDto, - IssueType issueType) { + public static IssueDetail from(Issue issue, ContentDto contentDto, AssigneeDto assigneeDto) { IssueDetail issueDetail = IssueDetail.builder() .issueId(issue.getId()) .key(issue.getNumber()) .title(issue.getTitle()) - .type(issueType.toString()) + .type(issue.getIssueType().toString()) .status(String.valueOf(issue.getStatus())) .label(String.valueOf(issue.getLabel())) .content(contentDto) .assignee(assigneeDto) + .startDate(String.valueOf(issue.getStartDate())) + .endDate(String.valueOf(issue.getEndDate())) .build(); - if (IssueType.EPIC.equals(issueType)) { - - Epic epic = Epic.extractFromIssue(issue); - issueDetail.startDate = epic.getStartDate() == null ? "" : epic.getStartDate().toString(); - issueDetail.endDate = epic.getEndDate() == null ? "" : epic.getEndDate().toString(); - } - else if (IssueType.STORY.equals(issueType)) { - - Story story = Story.extractFromIssue(issue); - issueDetail.startDate = story.getStartDate() == null ? "" : story.getStartDate().toString(); - issueDetail.endDate = story.getEndDate() == null ? "" : story.getEndDate().toString(); - } - else if (IssueType.TASK.equals(issueType)) { - - Task task = Task.extractFromIssue(issue); - issueDetail.startDate = task.getStartDate() == null ? "" : task.getStartDate().toString(); - issueDetail.endDate = task.getEndDate() == null ? "" : task.getEndDate().toString(); - } - return issueDetail; } } @@ -93,6 +75,7 @@ else if (IssueType.TASK.equals(issueType)) { @Getter @AllArgsConstructor @EqualsAndHashCode + @ToString public static class ContentDto { private String text; private List imagesURLs; @@ -114,6 +97,7 @@ public static ContentDto from(Issue issue) { @Getter @AllArgsConstructor @EqualsAndHashCode + @ToString public static class SubIssueDetail { private Long issueId; private String key; @@ -132,13 +116,13 @@ public SubIssueDetail() { this.assignee = new AssigneeDto(); } - public static SubIssueDetail from(Issue issue, IssueType issueType, AssigneeDto assigneeDto) { + public static SubIssueDetail from(Issue issue, AssigneeDto assigneeDto) { return SubIssueDetail.builder() .issueId(issue.getId()) .key(issue.getNumber()) .status(String.valueOf(issue.getStatus())) .label(String.valueOf(issue.getLabel())) - .type(issueType.toString()) + .type(issue.getIssueType().toString()) .title(issue.getTitle()) .assignee(assigneeDto) .build(); diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java index eb3b4d0..47ea858 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/EpicResponseDto.java @@ -1,11 +1,12 @@ package dynamicquad.agilehub.issue.dto.backlog; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.member.dto.AssigneeDto; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.ToString; public class EpicResponseDto { private EpicResponseDto() { @@ -14,6 +15,7 @@ private EpicResponseDto() { @Builder @Getter @EqualsAndHashCode + @ToString public static class EpicDetailForBacklog { private Long id; private String title; @@ -25,7 +27,7 @@ public static class EpicDetailForBacklog { private String endDate; private AssigneeDto assignee; - public static EpicDetailForBacklog from(Epic epic, String key, AssigneeDto assignee) { + public static EpicDetailForBacklog from(Issue epic, String key, AssigneeDto assignee) { return EpicDetailForBacklog.builder() .id(epic.getId()) .title(epic.getTitle()) @@ -43,6 +45,7 @@ public static EpicDetailForBacklog from(Epic epic, String key, AssigneeDto assig @Getter @Builder @EqualsAndHashCode + @ToString public static class EpicDetailWithStatistic { private EpicDetailForBacklog issue; private EpicStatistic statistic; diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java index 4d45df3..79fd6cf 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/StoryResponseDto.java @@ -1,7 +1,7 @@ package dynamicquad.agilehub.issue.dto.backlog; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Story; +import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.member.dto.AssigneeDto; import lombok.Builder; import lombok.EqualsAndHashCode; @@ -27,7 +27,7 @@ public static class StoryDetailForBacklog { private Long parentId; private AssigneeDto assignee; - public static StoryDetailForBacklog from(Story story, String projectKey, Long parentId, + public static StoryDetailForBacklog from(Issue story, String projectKey, Long parentId, AssigneeDto assigneeDto) { return StoryDetailForBacklog.builder() .id(story.getId()) diff --git a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java index 7852c56..1b68845 100644 --- a/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/issue/dto/backlog/TaskResponseDto.java @@ -1,7 +1,7 @@ package dynamicquad.agilehub.issue.dto.backlog; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Task; +import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.member.dto.AssigneeDto; import lombok.Builder; import lombok.EqualsAndHashCode; @@ -27,7 +27,7 @@ public static class TaskDetailForBacklog { private Long parentId; private AssigneeDto assignee; - public static TaskDetailForBacklog from(Task task, String projectKey, Long parentId, + public static TaskDetailForBacklog from(Issue task, String projectKey, Long parentId, AssigneeDto assigneeDto) { return TaskDetailForBacklog.builder() .id(task.getId()) diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/EpicRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/EpicRepository.java deleted file mode 100644 index fcb0cf2..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/repository/EpicRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -package dynamicquad.agilehub.issue.repository; - -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; -import dynamicquad.agilehub.project.domain.Project; -import java.time.LocalDate; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -@Repository -public interface EpicRepository extends JpaRepository { - - List findByProject(Project project); - - - @Query(value = "SELECT e.issue_id AS epicId, COUNT(DISTINCT s.issue_id) AS storiesCount, " - + "SUM(CASE WHEN i.status = 'DO' THEN 1 ELSE 0 END) AS statusDo, " - + "SUM(CASE WHEN i.status = 'PROGRESS' THEN 1 ELSE 0 END) AS statusProgress, " - + "SUM(CASE WHEN i.status = 'DONE' THEN 1 ELSE 0 END) AS statusDone " - + "FROM epic e " - + "LEFT JOIN (SELECT issue_id, epic_id FROM story WHERE epic_id IS NOT NULL) s ON e.issue_id = s.epic_id " - + "LEFT JOIN (SELECT issue_id, status from issue WHERE project_id = :projectId) i ON i.issue_id = s.issue_id " - + "GROUP BY e.issue_id;", nativeQuery = true) - List getEpicStatics(Long projectId); - - @Query(value = "SELECT i.content FROM Epic e inner join Issue i on e.id=i.id WHERE e.startDate <= :endDate AND e.endDate >= :startDate AND i.project.id=:projectId") - List findContentsByMonth(LocalDate endDate, LocalDate startDate, Long projectId); -} diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java index 85f097b..0bffea2 100644 --- a/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java +++ b/src/main/java/dynamicquad/agilehub/issue/repository/IssueRepository.java @@ -1,8 +1,11 @@ package dynamicquad.agilehub.issue.repository; +import dynamicquad.agilehub.issue.IssueType; import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; import dynamicquad.agilehub.project.domain.Project; import dynamicquad.agilehub.sprint.domain.Sprint; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -14,17 +17,56 @@ @Repository public interface IssueRepository extends JpaRepository { - @Query(value = "select issue_type from issue i where i.issue_id = :id", nativeQuery = true) + @Query(value = "select issue_type from issue_new i where i.issue_id = :id", nativeQuery = true) Optional findIssueTypeById(@Param("id") Long id); - List findByProject(Project project); - boolean existsByProjectIdAndId(Long projectId, Long issueId); - List findBySprint(Sprint sprint); @Modifying(clearAutomatically = true) @Query("update Issue i set i.sprint = null where i.sprint.id = :sprintId") void updateIssueSprintNull(Long sprintId); + + List findByParentIssueId(Long id); + + @Query("select i from Issue i where i.project = :project and i.issueType = :issueType") + List findEpicByProject(Project project, IssueType issueType); + + @Query(value = "SELECT i.issue_id AS epicId, " + + " COUNT(DISTINCT child.issue_id) AS storiesCount, " + + " SUM(CASE WHEN child.status = 'DO' THEN 1 ELSE 0 END) AS statusDo, " + + " SUM(CASE WHEN child.status = 'PROGRESS' THEN 1 ELSE 0 END) AS statusProgress, " + + " SUM(CASE WHEN child.status = 'DONE' THEN 1 ELSE 0 END) AS statusDone " + + " FROM issue_new i " + + " LEFT JOIN issue_new child " + + " ON i.issue_id = child.parent_issue_id " + + " WHERE i.issue_type = 'EPIC' " + + " AND i.project_id = :projectId " + + " GROUP BY i.issue_id", nativeQuery = true) + List getEpicStatics(Long projectId); + + @Query("SELECT i FROM Issue i WHERE i.parentIssueId = :epicId AND i.issueType = 'STORY'") + List findStoriesByEpicId(@Param("epicId") Long epicId); + + @Query("SELECT i FROM Issue i WHERE i.parentIssueId = :storyId AND i.issueType = 'TASK'") + List findTasksByStoryId(@Param("storyId") Long storyId); + + @Query("select i from Issue i where i.project = :project and i.issueType = 'EPIC'") + List findEpicsByProject(Project project); + + @Query("SELECT i FROM Issue i WHERE i.project = :project AND i.issueType = 'STORY'") + List findStoriesByProject(Project project); + + @Query("SELECT i FROM Issue i WHERE i.project = :project AND i.issueType = 'TASK'") + List findTasksByProject(Project project); + + @Query(value = "SELECT i.content FROM Issue i WHERE i.startDate <= :endDate AND i.endDate >= :startDate AND i.project.id=:projectId AND i.issueType = 'EPIC'") + List findEpicContentsByMonth(LocalDate startDate, LocalDate endDate, Long projectId); + + @Query(value = "SELECT i.content FROM Issue i WHERE i.startDate <= :endDate AND i.endDate >= :startDate AND i.project.id=:projectId AND i.issueType = 'STORY'") + List findStoryContentsByMonth(LocalDate startDate, LocalDate endDate, Long projectId); + + @Query(value = "SELECT i.content FROM Issue i WHERE i.startDate <= :endDate AND i.endDate >= :startDate AND i.project.id=:projectId AND i.issueType = 'TASK'") + List findTaskContentsByMonth(LocalDate startDate, LocalDate endDate, Long projectId); } diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/StoryRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/StoryRepository.java deleted file mode 100644 index 62a0692..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/repository/StoryRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package dynamicquad.agilehub.issue.repository; - -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.project.domain.Project; -import java.time.LocalDate; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -@Repository -public interface StoryRepository extends JpaRepository { - List findByEpicId(Long id); - - List findByProject(Project project); - - List findStoriesByEpicId(Long epicId); - - @Query(value = "SELECT i.content FROM Story s inner join Issue i on s.id=i.id WHERE s.startDate <= :endDate AND s.endDate >= :startDate AND i.project.id=:projectId") - List findContentsByMonth(LocalDate endDate, LocalDate startDate, Long projectId); -} diff --git a/src/main/java/dynamicquad/agilehub/issue/repository/TaskRepository.java b/src/main/java/dynamicquad/agilehub/issue/repository/TaskRepository.java deleted file mode 100644 index c45be43..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/repository/TaskRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package dynamicquad.agilehub.issue.repository; - -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.project.domain.Project; -import java.time.LocalDate; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; - -public interface TaskRepository extends JpaRepository { - - List findByStoryId(Long storyId); - - List findByProject(Project project); - - @Query(value = "SELECT i.content FROM Task t inner join Issue i on t.id=i.id WHERE t.startDate <= :endDate AND t.endDate >= :startDate AND i.project.id=:projectId") - List findContentsByMonth(LocalDate endDate, LocalDate startDate, Long projectId); -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java index 8f6acfa..4d3ba19 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueNumberGenerator.java @@ -22,16 +22,6 @@ public class IssueNumberGenerator { @Value("${redis.issue.number.prefix}") private String REDIS_ISSUE_PREFIX; -// @Transactional -// public String generate(String projectKey) { -// ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) -// .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); -// -// sequence.updateLastNumber(sequence.getNextNumber()); -// -// return projectKey + "-" + sequence.getLastNumber(); -// } - public String generate(String projectKey) { String redisKey = REDIS_ISSUE_PREFIX + projectKey; Long nextNumber = redisTemplate.opsForValue().increment(redisKey); @@ -65,6 +55,7 @@ public void syncWithDatabase() { } public void decrement(String projectKey) { + syncWithDatabase(); ProjectIssueSequence sequence = issueSequenceRepository.findByProjectKey(projectKey) .orElseThrow(() -> new IllegalArgumentException("ProjectIssueSequence not found")); diff --git a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java index 25ef81b..65878f9 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/command/IssueService.java @@ -1,17 +1,12 @@ package dynamicquad.agilehub.issue.service.command; -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Issue; import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.issue.dto.IssueRequestDto; import dynamicquad.agilehub.issue.dto.IssueRequestDto.EditIssuePeriod; import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.issue.service.IssueValidator; -import dynamicquad.agilehub.issue.service.factory.IssueFactoryProvider; +import dynamicquad.agilehub.issue.service.factory.IssueFactory; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import dynamicquad.agilehub.project.domain.Project; import dynamicquad.agilehub.project.service.MemberProjectService; @@ -19,7 +14,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @Service @@ -28,32 +22,29 @@ public class IssueService { private final ProjectQueryService projectQueryService; - private final IssueFactoryProvider issueFactoryProvider; private final IssueValidator issueValidator; private final MemberProjectService memberProjectService; private final IssueNumberGenerator issueNumberGenerator; + private final IssueFactory issueFactory; private final IssueRepository issueRepository; + @Transactional public Long createIssue(String key, IssueRequestDto.CreateIssue request, AuthMember authMember) { - Project project = validateMemberInProject(key, authMember); - - return issueFactoryProvider.getIssueFactory(request.getType()) - .createIssue(request, project); + return issueFactory.createIssue(request, project); } - @Transactional(isolation = Isolation.READ_COMMITTED) + @Transactional public void updateIssue(String key, Long issueId, IssueRequestDto.EditIssue request, AuthMember authMember) { Project project = validateMemberInProject(key, authMember); Issue issue = issueValidator.findIssue(issueId); issueValidator.validateIssueInProject(project.getId(), issueId); issueValidator.validateEqualsIssueType(issue, request.getType()); - issueFactoryProvider.getIssueFactory(request.getType()) - .updateIssue(issue, project, request); + issueFactory.updateIssue(issue, project, request); } @@ -71,7 +62,6 @@ public void deleteIssue(String key, Long issueId, AuthMember authMember) { @Transactional public void updateIssueStatus(String key, Long issueId, AuthMember authMember, IssueStatus updateStatus) { - Project project = validateMemberInProject(key, authMember); Issue issue = issueValidator.findIssue(issueId); issueValidator.validateIssueInProject(project.getId(), issueId); @@ -83,24 +73,10 @@ public void updateIssueStatus(String key, Long issueId, AuthMember authMember, public void updateIssuePeriod(String key, Long issueId, AuthMember authMember, EditIssuePeriod request) { Project project = validateMemberInProject(key, authMember); - Issue issue = issueValidator.findIssue(issueId); issueValidator.validateIssueInProject(project.getId(), issueId); - if (issue instanceof Epic epic) { - epic.updatePeriod(request.getStartDate(), request.getEndDate()); - return; - } - else if (issue instanceof Story story) { - story.updatePeriod(request.getStartDate(), request.getEndDate()); - return; - } - else if (issue instanceof Task task) { - task.updatePeriod(request.getStartDate(), request.getEndDate()); - return; - } - - throw new GeneralException(ErrorStatus.ISSUE_TYPE_NOT_FOUND); + issue.updatePeriod(request.getStartDate(), request.getEndDate()); } private Project validateMemberInProject(String key, AuthMember authMember) { diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java deleted file mode 100644 index 2c26623..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/EpicFactory.java +++ /dev/null @@ -1,127 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto.SubIssueDetail; -import dynamicquad.agilehub.issue.repository.IssueRepository; -import dynamicquad.agilehub.issue.repository.StoryRepository; -import dynamicquad.agilehub.issue.service.command.ImageService; -import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.AssigneeDto; -import dynamicquad.agilehub.member.service.MemberService; -import dynamicquad.agilehub.project.domain.Project; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component("EPIC_FACTORY") -@RequiredArgsConstructor -@Slf4j -public class EpicFactory implements IssueFactory { - - private final IssueRepository issueRepository; - private final StoryRepository storyRepository; - private final ImageService imageService; - private final IssueNumberGenerator issueNumberGenerator; - - private final MemberService memberService; - - @Value("${aws.s3.workingDirectory.issue}") - private String WORKING_DIRECTORY; - - @Transactional - @Override - public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - - // 이슈 번호 생성 - String issueNumber = issueNumberGenerator.generate(project.getKey()); - - // 멤버를 찾기 - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - Epic epic = toEntity(request, project, issueNumber, assignee); - - // 이슈 저장 - issueRepository.save(epic); - - // S3에 이미지 저장 - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - log.info("uploading images"); - imageService.saveImages(epic, request.getFiles(), WORKING_DIRECTORY); - } - return epic.getId(); - } - - @Transactional - @Override - public Long updateIssue(Issue issue, Project project, IssueRequestDto.EditIssue request) { - - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - - Epic epic = Epic.extractFromIssue(issue); - epic.updateEpic(request, assignee); - imageService.cleanupMismatchedImages(epic, request.getImageUrls(), WORKING_DIRECTORY); - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - imageService.saveImages(epic, request.getFiles(), WORKING_DIRECTORY); - } - return epic.getId(); - } - - @Override - public IssueResponseDto.ContentDto createContentDto(Issue issue) { - return IssueResponseDto.ContentDto.from(issue); - } - - @Override - public IssueResponseDto.IssueDetail createIssueDetail(Issue issue, IssueResponseDto.ContentDto contentDto, - AssigneeDto assigneeDto) { - return IssueResponseDto.IssueDetail.from(issue, contentDto, assigneeDto, IssueType.EPIC); - } - - @Override - public IssueResponseDto.SubIssueDetail createParentIssue(Issue issue) { - return new IssueResponseDto.SubIssueDetail(); - } - - @Override - public List createChildIssueDtos(Issue issue) { - Epic epic = Epic.extractFromIssue(issue); - List stories = storyRepository.findByEpicId(epic.getId()); - if (stories.isEmpty()) { - return List.of(); - } - - return stories.stream() - .map(this::getStoryToSubIssue) - .toList(); - } - - private SubIssueDetail getStoryToSubIssue(Story story) { - AssigneeDto assigneeDto = AssigneeDto.from(story); - return IssueResponseDto.SubIssueDetail.from(story, IssueType.STORY, assigneeDto); - } - - - private Epic toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee) { - return Epic.builder() - .title(request.getTitle()) - .content(request.getContent()) - .number(issueNumber) - .status(request.getStatus()) - .label(request.getLabel()) - .assignee(assignee) - .project(project) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .build(); - } - - -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImpl.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImpl.java new file mode 100644 index 0000000..5317552 --- /dev/null +++ b/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImpl.java @@ -0,0 +1,134 @@ +package dynamicquad.agilehub.issue.service.factory; + +import dynamicquad.agilehub.global.exception.GeneralException; +import dynamicquad.agilehub.global.header.status.ErrorStatus; +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.CreateIssue; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.EditIssue; +import dynamicquad.agilehub.issue.dto.IssueResponseDto; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.ContentDto; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.IssueDetail; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.SubIssueDetail; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.issue.service.command.ImageService; +import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.dto.AssigneeDto; +import dynamicquad.agilehub.member.service.MemberService; +import dynamicquad.agilehub.project.domain.Project; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Slf4j +@Service // Model 계층, 다른 서비스계층에 종속되므로, @Component 대신 @Service 사용 +public class IssueFactoryImpl implements IssueFactory { + + private final IssueRepository issueRepository; + + private final ImageService imageService; + private final IssueNumberGenerator issueNumberGenerator; + private final MemberService memberService; + + @Value("${aws.s3.workingDirectory.issue}") + private String WORKING_DIRECTORY; + + @Override + @Transactional + public Long createIssue(CreateIssue request, Project project) { + // 이슈 번호 생성 + String issueNumber = issueNumberGenerator.generate(project.getKey()); + + // 멤버를 찾기 + Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); + Issue issue = toEntity(request, project, issueNumber, assignee); + + // 이슈 저장 + issueRepository.save(issue); + + // S3에 이미지 저장 + if (request.getFiles() != null && !request.getFiles().isEmpty()) { + log.info("uploading images"); + imageService.saveImages(issue, request.getFiles(), WORKING_DIRECTORY); + } + + return issue.getId(); + } + + @Transactional + @Override + public Long updateIssue(Issue issue, Project project, EditIssue request) { + Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); + issue.updateIssue(request, assignee); + + imageService.cleanupMismatchedImages(issue, request.getImageUrls(), WORKING_DIRECTORY); + if (request.getFiles() != null && !request.getFiles().isEmpty()) { + imageService.saveImages(issue, request.getFiles(), WORKING_DIRECTORY); + } + return issue.getId(); + } + + private Issue toEntity(CreateIssue request, Project project, String issueNumber, Member assignee) { + if (IssueType.EPIC.equals(request.getType())) { + return Issue.createEpic(request, assignee, issueNumber, project); + } + else if (IssueType.STORY.equals(request.getType())) { + return Issue.createStory(request, assignee, issueNumber, project); + } + else if (IssueType.TASK.equals(request.getType())) { + return Issue.createTask(request, assignee, issueNumber, project); + } + + throw new IllegalArgumentException("Invalid issue type"); + } + + + @Override + public ContentDto createContentDto(Issue issue) { + return IssueResponseDto.ContentDto.from(issue); + } + + @Override + public IssueDetail createIssueDetail(Issue issue, ContentDto contentDto, AssigneeDto assigneeDto) { + return IssueResponseDto.IssueDetail.from(issue, contentDto, assigneeDto); + } + + @Override + public SubIssueDetail createParentIssue(Issue issue) { + Long parentIssueId = issue.getParentIssueId(); + + if (parentIssueId == null) { + return new IssueResponseDto.SubIssueDetail(); + } + + Issue parentIssue = issueRepository.findById(parentIssueId).orElseThrow(() -> { + log.error("Parent issue not found. issueId: {}", parentIssueId); + throw new GeneralException(ErrorStatus.ISSUE_NOT_FOUND); + }); + + AssigneeDto assigneeDto = AssigneeDto.from(parentIssue); + return IssueResponseDto.SubIssueDetail.from(parentIssue, assigneeDto); + } + + @Override + public List createChildIssueDtos(Issue issue) { + List childIssues = issueRepository.findByParentIssueId(issue.getId()); + if (childIssues.isEmpty()) { + return List.of(); + } + + return childIssues.stream() + .map(childIssue -> { + AssigneeDto assigneeDto = AssigneeDto.from(childIssue); + return IssueResponseDto.SubIssueDetail.from(childIssue, assigneeDto); + }) + .toList(); + } + + +} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryProvider.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryProvider.java deleted file mode 100644 index 31b8bf4..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryProvider.java +++ /dev/null @@ -1,30 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; -import java.util.EnumMap; -import java.util.Map; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.stereotype.Component; - -@Component -public class IssueFactoryProvider { - private final Map factories = new EnumMap<>(IssueType.class); - - public IssueFactoryProvider(@Qualifier("EPIC_FACTORY") IssueFactory epicFactory, - @Qualifier("STORY_FACTORY") IssueFactory storyFactory, - @Qualifier("TASK_FACTORY") IssueFactory taskFactory) { - factories.put(IssueType.EPIC, epicFactory); - factories.put(IssueType.STORY, storyFactory); - factories.put(IssueType.TASK, taskFactory); - } - - public IssueFactory getIssueFactory(IssueType type) { - if (factories.containsKey(type)) { - return factories.get(type); - } else { - throw new GeneralException(ErrorStatus.ISSUE_TYPE_NOT_FOUND); - } - } -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java deleted file mode 100644 index a137720..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/StoryFactory.java +++ /dev/null @@ -1,160 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto.SubIssueDetail; -import dynamicquad.agilehub.issue.repository.IssueRepository; -import dynamicquad.agilehub.issue.repository.TaskRepository; -import dynamicquad.agilehub.issue.service.command.ImageService; -import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.AssigneeDto; -import dynamicquad.agilehub.member.service.MemberService; -import dynamicquad.agilehub.project.domain.Project; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component("STORY_FACTORY") -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Slf4j -public class StoryFactory implements IssueFactory { - - - private final IssueRepository issueRepository; - private final TaskRepository taskRepository; - private final ImageService imageService; - private final IssueNumberGenerator issueNumberGenerator; - - private final MemberService memberService; - - @Value("${aws.s3.workingDirectory.issue}") - private String WORKING_DIRECTORY; - - @Transactional - @Override - public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - - // 이슈 번호 생성 - String issueNumber = issueNumberGenerator.generate(project.getKey()); - - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - Epic upEpic = retrieveEpicFromParentIssue(request.getParentId()); - Story story = toEntity(request, project, issueNumber, assignee, upEpic); - - issueRepository.save(story); - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - imageService.saveImages(story, request.getFiles(), WORKING_DIRECTORY); - } - - return story.getId(); - } - - @Override - public Long updateIssue(Issue issue, Project project, IssueRequestDto.EditIssue request) { - - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - - Story story = Story.extractFromIssue(issue); - Epic upEpic = retrieveEpicFromParentIssue(request.getParentId()); - story.updateStory(request, assignee, upEpic); - - imageService.cleanupMismatchedImages(story, request.getImageUrls(), WORKING_DIRECTORY); - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - imageService.saveImages(story, request.getFiles(), WORKING_DIRECTORY); - } - return story.getId(); - } - - - @Override - public IssueResponseDto.ContentDto createContentDto(Issue issue) { - return IssueResponseDto.ContentDto.from(issue); - } - - @Override - public IssueResponseDto.IssueDetail createIssueDetail(Issue issue, IssueResponseDto.ContentDto contentDto, - AssigneeDto assigneeDto) { - return IssueResponseDto.IssueDetail.from(issue, contentDto, assigneeDto, IssueType.STORY); - } - - @Override - public IssueResponseDto.SubIssueDetail createParentIssue(Issue issue) { - Story story = Story.extractFromIssue(issue); - Epic epic = story.getEpic(); - - if (epic == null) { - return new IssueResponseDto.SubIssueDetail(); - } - AssigneeDto assigneeDto = AssigneeDto.from(epic); - - return IssueResponseDto.SubIssueDetail.from(epic, IssueType.EPIC, assigneeDto); - } - - @Override - public List createChildIssueDtos(Issue issue) { - Story story = Story.extractFromIssue(issue); - List tasks = taskRepository.findByStoryId(story.getId()); - if (tasks.isEmpty()) { - return List.of(); - } - - return tasks.stream() - .map(this::getTaskToSubIssue) - .toList(); - } - - - private SubIssueDetail getTaskToSubIssue(Task task) { - AssigneeDto assigneeDto = AssigneeDto.from(task); - return IssueResponseDto.SubIssueDetail.from(task, IssueType.TASK, assigneeDto); - } - - public Epic retrieveEpicFromParentIssue(Long parentId) { - if (parentId == null) { - return null; - } - validateParentIssue(parentId); - return (Epic) issueRepository.findById(parentId) - .orElseThrow(() -> new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_FOUND)); - - } - - private void validateParentIssue(Long parentId) { - String type = issueRepository.findIssueTypeById(parentId) - .orElseThrow(() -> new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_FOUND)); - - if (!IssueType.EPIC.equals(IssueType.valueOf(type))) { - throw new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_EPIC); - } - } - - private Story toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee, - Epic upEpic) { - return Story.builder() - .title(request.getTitle()) - .content(request.getContent()) - .number(issueNumber) - .status(request.getStatus()) - .label(request.getLabel()) - .assignee(assignee) - .project(project) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .epic(upEpic) - .build(); - } - - -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java b/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java deleted file mode 100644 index d28a830..0000000 --- a/src/main/java/dynamicquad/agilehub/issue/service/factory/TaskFactory.java +++ /dev/null @@ -1,138 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto; -import dynamicquad.agilehub.issue.repository.IssueRepository; -import dynamicquad.agilehub.issue.service.command.ImageService; -import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.AssigneeDto; -import dynamicquad.agilehub.member.service.MemberService; -import dynamicquad.agilehub.project.domain.Project; -import java.util.List; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -@Component("TASK_FACTORY") -@Transactional(readOnly = true) -@RequiredArgsConstructor -@Slf4j -public class TaskFactory implements IssueFactory { - - private final IssueRepository issueRepository; - private final ImageService imageService; - private final MemberService memberService; - private final IssueNumberGenerator issueNumberGenerator; - - @Value("${aws.s3.workingDirectory.issue}") - private String WORKING_DIRECTORY; - - @Transactional - @Override - public Long createIssue(IssueRequestDto.CreateIssue request, Project project) { - - String issueNumber = issueNumberGenerator.generate(project.getKey()); - - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - Story upStory = retrieveStoryFromParentIssue(request.getParentId()); - Task task = toEntity(request, project, issueNumber, assignee, upStory); - - issueRepository.save(task); - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - imageService.saveImages(task, request.getFiles(), WORKING_DIRECTORY); - } - return task.getId(); - } - - @Override - public Long updateIssue(Issue issue, Project project, IssueRequestDto.EditIssue request) { - - Member assignee = memberService.findMember(request.getAssigneeId(), project.getId()); - - Task task = Task.extractFromIssue(issue); - Story upStory = retrieveStoryFromParentIssue(request.getParentId()); - task.updateTask(request, assignee, upStory); - - imageService.cleanupMismatchedImages(task, request.getImageUrls(), WORKING_DIRECTORY); - if (request.getFiles() != null && !request.getFiles().isEmpty()) { - imageService.saveImages(task, request.getFiles(), WORKING_DIRECTORY); - } - - return task.getId(); - } - - @Override - public IssueResponseDto.ContentDto createContentDto(Issue issue) { - return IssueResponseDto.ContentDto.from(issue); - } - - @Override - public IssueResponseDto.IssueDetail createIssueDetail(Issue issue, IssueResponseDto.ContentDto contentDto, - AssigneeDto assigneeDto) { - return IssueResponseDto.IssueDetail.from(issue, contentDto, assigneeDto, IssueType.TASK); - } - - - @Override - public IssueResponseDto.SubIssueDetail createParentIssue(Issue issue) { - Task task = Task.extractFromIssue(issue); - Story story = task.getStory(); - if (story == null) { - return null; - } - AssigneeDto assigneeDto = AssigneeDto.from(story); - return IssueResponseDto.SubIssueDetail.from(story, IssueType.STORY, assigneeDto); - } - - - @Override - public List createChildIssueDtos(Issue issue) { - return List.of(); - } - - private Story retrieveStoryFromParentIssue(Long parentId) { - if (parentId == null) { - return null; - } - validateParentIssue(parentId); - return (Story) issueRepository.findById(parentId) - .orElseThrow(() -> new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_FOUND)); - } - - private void validateParentIssue(Long parentId) { - String type = issueRepository.findIssueTypeById(parentId) - .orElseThrow(() -> new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_FOUND)); - - if (!IssueType.STORY.equals(IssueType.valueOf(type))) { - throw new GeneralException(ErrorStatus.PARENT_ISSUE_NOT_STORY); - } - - } - - private Task toEntity(IssueRequestDto.CreateIssue request, Project project, String issueNumber, Member assignee, - Story upStory) { - return Task.builder() - .title(request.getTitle()) - .content(request.getContent()) - .number(issueNumber) - .status(request.getStatus()) - .label(request.getLabel()) - .assignee(assignee) - .project(project) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) - .story(upStory) - .build(); - } - - -} diff --git a/src/main/java/dynamicquad/agilehub/issue/service/query/IssueQueryService.java b/src/main/java/dynamicquad/agilehub/issue/service/query/IssueQueryService.java index 8155eb4..a8e4009 100644 --- a/src/main/java/dynamicquad/agilehub/issue/service/query/IssueQueryService.java +++ b/src/main/java/dynamicquad/agilehub/issue/service/query/IssueQueryService.java @@ -3,20 +3,14 @@ import dynamicquad.agilehub.global.exception.GeneralException; import dynamicquad.agilehub.global.header.status.ErrorStatus; import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.issue.dto.IssueResponseDto; import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; import dynamicquad.agilehub.issue.dto.backlog.StoryResponseDto; import dynamicquad.agilehub.issue.dto.backlog.TaskResponseDto; -import dynamicquad.agilehub.issue.repository.EpicRepository; -import dynamicquad.agilehub.issue.repository.StoryRepository; -import dynamicquad.agilehub.issue.repository.TaskRepository; +import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.issue.service.IssueValidator; import dynamicquad.agilehub.issue.service.factory.IssueFactory; -import dynamicquad.agilehub.issue.service.factory.IssueFactoryProvider; import dynamicquad.agilehub.member.dto.AssigneeDto; import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; import dynamicquad.agilehub.project.domain.Project; @@ -32,28 +26,25 @@ @Service @RequiredArgsConstructor -@Transactional(readOnly = true) @Slf4j public class IssueQueryService { - private final IssueFactoryProvider issueFactoryProvider; + private final IssueFactory issueFactory; + private final IssueRepository issueRepository; + private final IssueValidator issueValidator; private final ProjectQueryService projectQueryService; private final MemberProjectService memberProjectService; - private final EpicRepository epicRepository; - private final StoryRepository storyRepository; - private final TaskRepository taskRepository; - + @Transactional(readOnly = true) public IssueResponseDto.IssueAndSubIssueDetail getIssue(String key, Long issueId, AuthMember authMember) { Long projectId = projectQueryService.findProjectId(key); memberProjectService.validateMemberInProject(authMember.getId(), projectId); + Issue issue = issueValidator.findIssue(issueId); issueValidator.validateIssueInProject(projectId, issueId); - IssueFactory issueFactory = issueFactoryProvider.getIssueFactory(issueValidator.getIssueType(issueId)); - IssueResponseDto.IssueDetail issueDetail = issueFactory.createIssueDetail(issue, issueFactory.createContentDto(issue), AssigneeDto.from(issue)); IssueResponseDto.SubIssueDetail parentIssue = issueFactory.createParentIssue(issue); @@ -63,22 +54,24 @@ public IssueResponseDto.IssueAndSubIssueDetail getIssue(String key, Long issueId } + @Transactional(readOnly = true) public List getEpicsWithStats(String key, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List epicsByProject = epicRepository.findByProject(project); + List epicsByProject = issueRepository.findEpicByProject(project, IssueType.EPIC); List epicDetailForBacklogs = getEpicResponses(epicsByProject, project); - List epicStatics = epicRepository.getEpicStatics(project.getId()); + List epicStatics = issueRepository.getEpicStatics(project.getId()); return getEpicWithStatisticResponses(epicDetailForBacklogs, epicStatics); } + @Transactional(readOnly = true) public List getStoriesByEpic(String key, Long epicId, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List storiesByEpic = storyRepository.findStoriesByEpicId(epicId); + List storiesByEpic = issueRepository.findStoriesByEpicId(epicId); return storiesByEpic.stream() .map(story -> { @@ -88,10 +81,11 @@ public List getStoriesByEpic(String key, .toList(); } + @Transactional(readOnly = true) public List getTasksByStory(String key, Long storyId, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List tasksByStory = taskRepository.findByStoryId(storyId); + List tasksByStory = issueRepository.findTasksByStoryId(storyId); return tasksByStory.stream() .map(task -> { @@ -101,10 +95,11 @@ public List getTasksByStory(String key, Lo .toList(); } + @Transactional(readOnly = true) public List getEpics(String key, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List epicsByProject = epicRepository.findByProject(project); + List epicsByProject = issueRepository.findEpicsByProject(project); return epicsByProject.stream() .map(epic -> { @@ -115,10 +110,11 @@ public List getEpics(String key, AuthMember au } + @Transactional(readOnly = true) public List getStories(String key, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List storiesByProject = storyRepository.findByProject(project); + List storiesByProject = issueRepository.findStoriesByProject(project); return storiesByProject.stream() .map(story -> { @@ -128,10 +124,11 @@ public List getStories(String key, AuthMember .toList(); } + @Transactional(readOnly = true) public List getTasks(String key, AuthMember authMember) { Project project = projectQueryService.findProject(key); memberProjectService.validateMemberInProject(authMember.getId(), project.getId()); - List tasksByProject = taskRepository.findByProject(project); + List tasksByProject = issueRepository.findTasksByProject(project); return tasksByProject.stream() .map(task -> { @@ -141,15 +138,16 @@ public List getTasks(String key, AuthMember au .toList(); } + @Transactional(readOnly = true) public MonthlyReportDto getIssuesForMonth(String yearMonth, Long projectId) { YearMonth ym = YearMonth.parse(yearMonth); // "2024-05"와 같은 형식의 문자열 LocalDate startDate = ym.atDay(1); LocalDate endDate = ym.atEndOfMonth(); - List contentsByEpic = epicRepository.findContentsByMonth(endDate, startDate, projectId); - List contentsByStory = storyRepository.findContentsByMonth(endDate, startDate, projectId); - List contentsByTask = taskRepository.findContentsByMonth(endDate, startDate, projectId); + List contentsByEpic = issueRepository.findEpicContentsByMonth(endDate, startDate, projectId); + List contentsByStory = issueRepository.findStoryContentsByMonth(endDate, startDate, projectId); + List contentsByTask = issueRepository.findTaskContentsByMonth(endDate, startDate, projectId); return new MonthlyReportDto(contentsByEpic, contentsByStory, contentsByTask); @@ -171,7 +169,7 @@ private List getEpicWithStatisticRespon .toList(); } - private List getEpicResponses(List epicsByProject, Project project) { + private List getEpicResponses(List epicsByProject, Project project) { return epicsByProject.stream() .map(epic -> { AssigneeDto assignee = AssigneeDto.from(epic); diff --git a/src/main/java/dynamicquad/agilehub/member/dto/AssigneeDto.java b/src/main/java/dynamicquad/agilehub/member/dto/AssigneeDto.java index e7be7e4..01a77b5 100644 --- a/src/main/java/dynamicquad/agilehub/member/dto/AssigneeDto.java +++ b/src/main/java/dynamicquad/agilehub/member/dto/AssigneeDto.java @@ -6,11 +6,13 @@ import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; +import lombok.ToString; @Builder @Getter @AllArgsConstructor @EqualsAndHashCode +@ToString public class AssigneeDto { private Long id; private String name; diff --git a/src/main/java/dynamicquad/agilehub/project/domain/Project.java b/src/main/java/dynamicquad/agilehub/project/domain/Project.java index ce4158b..f0dc7e8 100644 --- a/src/main/java/dynamicquad/agilehub/project/domain/Project.java +++ b/src/main/java/dynamicquad/agilehub/project/domain/Project.java @@ -35,7 +35,7 @@ public class Project extends BaseEntity { private String key; @Builder - private Project(String name, String key) { + public Project(String name, String key) { this.name = name; this.key = key; } diff --git a/src/main/java/dynamicquad/agilehub/sprint/dto/SprintResponseDto.java b/src/main/java/dynamicquad/agilehub/sprint/dto/SprintResponseDto.java index 65e3e6b..425aa60 100644 --- a/src/main/java/dynamicquad/agilehub/sprint/dto/SprintResponseDto.java +++ b/src/main/java/dynamicquad/agilehub/sprint/dto/SprintResponseDto.java @@ -1,9 +1,7 @@ package dynamicquad.agilehub.sprint.dto; -import dynamicquad.agilehub.issue.domain.Epic; +import dynamicquad.agilehub.issue.IssueType; import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; import dynamicquad.agilehub.member.dto.AssigneeDto; import dynamicquad.agilehub.sprint.domain.Sprint; import java.util.List; @@ -96,35 +94,21 @@ public IssueDetailInSprint() { public static IssueDetailInSprint from(Issue issue, String key, AssigneeDto assigneeDto) { - if (issue instanceof Epic) { + if (issue.getIssueType().equals(IssueType.EPIC)) { return new IssueDetailInSprint(); - } else if (issue instanceof Story story) { - return IssueDetailInSprint.builder() - .title(story.getTitle()) - .type("STORY") - .issueId(story.getId()) - .key(key + "-" + story.getNumber()) - .status(String.valueOf(story.getStatus())) - .label(String.valueOf(story.getLabel())) - .startDate(story.getStartDate() != null ? story.getStartDate().toString() : "") - .endDate(story.getEndDate() != null ? story.getEndDate().toString() : "") - .assigneeDto(assigneeDto) - .build(); - } else if (issue instanceof Task task) { - return IssueDetailInSprint.builder() - .title(task.getTitle()) - .type("TASK") - .issueId(task.getId()) - .key(key + "-" + task.getNumber()) - .status(String.valueOf(task.getStatus())) - .label(String.valueOf(task.getLabel())) - .startDate(task.getStartDate() != null ? task.getStartDate().toString() : "") - .endDate(task.getEndDate() != null ? task.getEndDate().toString() : "") - .assigneeDto(assigneeDto) - .build(); } - return new IssueDetailInSprint(); + return IssueDetailInSprint.builder() + .title(issue.getTitle()) + .type(issue.getIssueType().toString()) + .issueId(issue.getId()) + .key(issue.getNumber()) + .status(String.valueOf(issue.getStatus())) + .label(String.valueOf(issue.getLabel())) + .startDate(issue.getStartDate() != null ? issue.getStartDate().toString() : "") + .endDate(issue.getEndDate() != null ? issue.getEndDate().toString() : "") + .assigneeDto(assigneeDto) + .build(); } } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index c4b53c3..eae707c 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -6,7 +6,6 @@ spring: import: - optional:classpath:.env[.properties] - h2: console: enabled: true @@ -24,13 +23,6 @@ spring: open-in-view: false - sql: init: mode: never - -logging: - level: - org.hibernate.SQL: debug - root: info - org.hibernate.orm.jdbc.bind: trace diff --git a/src/test/java/dynamicquad/agilehub/config/MySQLTestContainer.java b/src/test/java/dynamicquad/agilehub/config/MySQLTestContainer.java new file mode 100644 index 0000000..1053dbe --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/config/MySQLTestContainer.java @@ -0,0 +1,47 @@ +package dynamicquad.agilehub.config; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.utility.DockerImageName; + +public class MySQLTestContainer implements BeforeAllCallback, AfterAllCallback { + + private static final String MYSQL_DOCKER_IMAGE = "mysql:8.0"; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + MYSQL_CONTAINER = new MySQLContainer<>(DockerImageName.parse(MYSQL_DOCKER_IMAGE)) + .withDatabaseName("test-db") + .withUsername("test") + .withPassword("test") + .withReuse(true); // 컨테이너 재사용 가능하도록 설정 + } + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + try { + MYSQL_CONTAINER.start(); + } catch (Exception e) { + throw new RuntimeException("MySQL Container 시작 실패", e); + } + } + + @Override + public void afterAll(ExtensionContext context) throws Exception { + if (MYSQL_CONTAINER.isRunning()) { + MYSQL_CONTAINER.stop(); + } + } + + @DynamicPropertySource + static void setMySQLProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", MYSQL_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.username", MYSQL_CONTAINER::getUsername); + registry.add("spring.datasource.password", MYSQL_CONTAINER::getPassword); + registry.add("spring.datasource.driver-class-name", MYSQL_CONTAINER::getDriverClassName); + } +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java index cc35139..8c0cabe 100644 --- a/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/comment/CommentServiceTest.java @@ -1,131 +1,131 @@ -package dynamicquad.agilehub.issue.comment; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.comment.domain.Comment; -import dynamicquad.agilehub.comment.response.CommentResponse.CommentCreateResponse; -import dynamicquad.agilehub.comment.service.CommentService; -import dynamicquad.agilehub.config.RedisTestContainer; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -@Import(RedisTestContainer.class) -class CommentServiceTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private CommentService commentService; - - @Test - @Transactional - void 멤버가_특정이슈에_코멘트를_작성하면_해당코멘트가_저장된다() { - // given - Project project1 = createProject("프로젝트1", "project12311"); - em.persist(project1); - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - - Member member = Member.builder() - .name("member1") - .build(); - em.persist(member); - - MemberProject memberProject = MemberProject.builder() - .member(member) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject); - - AuthMember authMember = AuthMember.builder() - .id(member.getId()) - .build(); - - // when - CommentCreateResponse commentCreateResponse = commentService.createComment(project1.getKey(), epic1P1.getId(), - "코멘트 내용", authMember); - - // then - assertThat(commentCreateResponse).isNotNull(); - assertThat(commentCreateResponse).extracting("content").isEqualTo("코멘트 내용"); - assertThat(commentCreateResponse).extracting("issueId").isEqualTo(epic1P1.getId()); - assertThat(commentCreateResponse).extracting("writerId").isEqualTo(member.getId()); - System.out.println("작성한 시간: " + commentCreateResponse.getCreatedAt()); - - } - - @Test - @Transactional - void 특정이슈의_코멘트를_수정하면_반영된다() { - //given - Project project1 = createProject("프로젝트1", "project12311"); - em.persist(project1); - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - - Member member = Member.builder() - .name("member1") - .build(); - em.persist(member); - - MemberProject memberProject = MemberProject.builder() - .member(member) - .project(project1) - .role(MemberProjectRole.ADMIN) - .build(); - em.persist(memberProject); - - Comment comment = Comment.builder() - .content("코멘트 내용") - .writer(member) - .issue(epic1P1) - .build(); - - em.persist(comment); - - AuthMember authMember = AuthMember.builder() - .id(member.getId()) - .build(); - - //when - commentService.updateComment(project1.getKey(), epic1P1.getId(), comment.getId(), "수정된 코멘트 내용", authMember); - - //then - Comment findComment = em.find(Comment.class, comment.getId()); - assertThat(findComment.getContent()).isEqualTo("수정된 코멘트 내용"); - } - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .status(IssueStatus.DO) - .project(project) - .build(); - } - -} \ No newline at end of file +//package dynamicquad.agilehub.issue.comment; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//import dynamicquad.agilehub.comment.domain.Comment; +//import dynamicquad.agilehub.comment.response.CommentResponse.CommentCreateResponse; +//import dynamicquad.agilehub.comment.service.CommentService; +//import dynamicquad.agilehub.config.RedisTestContainer; +//import dynamicquad.agilehub.issue.domain.Epic; +//import dynamicquad.agilehub.issue.domain.IssueStatus; +//import dynamicquad.agilehub.member.domain.Member; +//import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +//import dynamicquad.agilehub.project.domain.MemberProject; +//import dynamicquad.agilehub.project.domain.MemberProjectRole; +//import dynamicquad.agilehub.project.domain.Project; +//import jakarta.persistence.EntityManager; +//import jakarta.persistence.PersistenceContext; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.context.annotation.Import; +//import org.springframework.test.context.ActiveProfiles; +//import org.springframework.transaction.annotation.Transactional; +// +//@ActiveProfiles("test") +//@SpringBootTest +//@Import(RedisTestContainer.class) +//class CommentServiceTest { +// +// @PersistenceContext +// EntityManager em; +// +// @Autowired +// private CommentService commentService; +// +// @Test +// @Transactional +// void 멤버가_특정이슈에_코멘트를_작성하면_해당코멘트가_저장된다() { +// // given +// Project project1 = createProject("프로젝트1", "project12311"); +// em.persist(project1); +// Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); +// em.persist(epic1P1); +// +// Member member = Member.builder() +// .name("member1") +// .build(); +// em.persist(member); +// +// MemberProject memberProject = MemberProject.builder() +// .member(member) +// .project(project1) +// .role(MemberProjectRole.ADMIN) +// .build(); +// em.persist(memberProject); +// +// AuthMember authMember = AuthMember.builder() +// .id(member.getId()) +// .build(); +// +// // when +// CommentCreateResponse commentCreateResponse = commentService.createComment(project1.getKey(), epic1P1.getId(), +// "코멘트 내용", authMember); +// +// // then +// assertThat(commentCreateResponse).isNotNull(); +// assertThat(commentCreateResponse).extracting("content").isEqualTo("코멘트 내용"); +// assertThat(commentCreateResponse).extracting("issueId").isEqualTo(epic1P1.getId()); +// assertThat(commentCreateResponse).extracting("writerId").isEqualTo(member.getId()); +// System.out.println("작성한 시간: " + commentCreateResponse.getCreatedAt()); +// +// } +// +// @Test +// @Transactional +// void 특정이슈의_코멘트를_수정하면_반영된다() { +// //given +// Project project1 = createProject("프로젝트1", "project12311"); +// em.persist(project1); +// Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); +// em.persist(epic1P1); +// +// Member member = Member.builder() +// .name("member1") +// .build(); +// em.persist(member); +// +// MemberProject memberProject = MemberProject.builder() +// .member(member) +// .project(project1) +// .role(MemberProjectRole.ADMIN) +// .build(); +// em.persist(memberProject); +// +// Comment comment = Comment.builder() +// .content("코멘트 내용") +// .writer(member) +// .issue(epic1P1) +// .build(); +// +// em.persist(comment); +// +// AuthMember authMember = AuthMember.builder() +// .id(member.getId()) +// .build(); +// +// //when +// commentService.updateComment(project1.getKey(), epic1P1.getId(), comment.getId(), "수정된 코멘트 내용", authMember); +// +// //then +// Comment findComment = em.find(Comment.class, comment.getId()); +// assertThat(findComment.getContent()).isEqualTo("수정된 코멘트 내용"); +// } +// +// private Project createProject(String projectName, String projectKey) { +// return Project.builder() +// .name(projectName) +// .key(projectKey) +// .build(); +// } +// +// private Epic createEpic(String title, String content, Project project) { +// return Epic.builder() +// .title(title) +// .content(content) +// .status(IssueStatus.DO) +// .project(project) +// .build(); +// } +// +//} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/comment/domain/CommentRepositoryTest.java b/src/test/java/dynamicquad/agilehub/issue/comment/domain/CommentRepositoryTest.java index 71e7ce7..e888560 100644 --- a/src/test/java/dynamicquad/agilehub/issue/comment/domain/CommentRepositoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/comment/domain/CommentRepositoryTest.java @@ -1,89 +1,89 @@ -package dynamicquad.agilehub.issue.comment.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.comment.domain.Comment; -import dynamicquad.agilehub.comment.domain.CommentRepository; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class CommentRepositoryTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private CommentRepository commentRepository; - - @Test - @Transactional - void 이슈에_대한_전체_댓글조회() { - // given - Project project1 = createProject("프로젝트1", "p1"); - em.persist(project1); - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - Member member1 = Member.builder() - .name("member1") - .build(); - em.persist(member1); - - Comment comment1 = Comment.builder() - .content("코멘트 내용") - .writer(member1) - .issue(epic1P1) - .build(); - em.persist(comment1); - - Comment comment2 = Comment.builder() - .content("코멘트 내용3") - .writer(member1) - .issue(epic1P1) - .build(); - em.persist(comment2); - - Comment comment3 = Comment.builder() - .content("코멘트 내용2") - .writer(member1) - .issue(epic1P1) - .build(); - em.persist(comment3); - // when - List byIssue = commentRepository.findByIssueOrderByCreatedAtAsc(epic1P1); - // then - assertThat(byIssue).hasSize(3); - assertThat(byIssue).extracting(Comment::getWriter).contains(member1); - assertThat(byIssue).extracting(Comment::getContent).containsExactly("코멘트 내용", "코멘트 내용3", "코멘트 내용2"); - - } - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .status(IssueStatus.DO) - .project(project) - .build(); - } - - -} \ No newline at end of file +//package dynamicquad.agilehub.issue.comment.domain; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//import dynamicquad.agilehub.comment.domain.Comment; +//import dynamicquad.agilehub.comment.domain.CommentRepository; +//import dynamicquad.agilehub.issue.domain.IssueStatus; +//import dynamicquad.agilehub.issue.domain.Epic; +//import dynamicquad.agilehub.member.domain.Member; +//import dynamicquad.agilehub.project.domain.Project; +//import jakarta.persistence.EntityManager; +//import jakarta.persistence.PersistenceContext; +//import java.util.List; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +//import org.springframework.transaction.annotation.Transactional; +// +//@ActiveProfiles("test") +//@SpringBootTest +//class CommentRepositoryTest { +// +// @PersistenceContext +// EntityManager em; +// +// @Autowired +// private CommentRepository commentRepository; +// +// @Test +// @Transactional +// void 이슈에_대한_전체_댓글조회() { +// // given +// Project project1 = createProject("프로젝트1", "p1"); +// em.persist(project1); +// Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); +// em.persist(epic1P1); +// Member member1 = Member.builder() +// .name("member1") +// .build(); +// em.persist(member1); +// +// Comment comment1 = Comment.builder() +// .content("코멘트 내용") +// .writer(member1) +// .issue(epic1P1) +// .build(); +// em.persist(comment1); +// +// Comment comment2 = Comment.builder() +// .content("코멘트 내용3") +// .writer(member1) +// .issue(epic1P1) +// .build(); +// em.persist(comment2); +// +// Comment comment3 = Comment.builder() +// .content("코멘트 내용2") +// .writer(member1) +// .issue(epic1P1) +// .build(); +// em.persist(comment3); +// // when +// List byIssue = commentRepository.findByIssueOrderByCreatedAtAsc(epic1P1); +// // then +// assertThat(byIssue).hasSize(3); +// assertThat(byIssue).extracting(Comment::getWriter).contains(member1); +// assertThat(byIssue).extracting(Comment::getContent).containsExactly("코멘트 내용", "코멘트 내용3", "코멘트 내용2"); +// +// } +// +// private Project createProject(String projectName, String projectKey) { +// return Project.builder() +// .name(projectName) +// .key(projectKey) +// .build(); +// } +// +// private Epic createEpic(String title, String content, Project project) { +// return Epic.builder() +// .title(title) +// .content(content) +// .status(IssueStatus.DO) +// .project(project) +// .build(); +// } +// +// +//} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java deleted file mode 100644 index d8d9eb3..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/domain/EpicTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package dynamicquad.agilehub.issue.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.domain.MemberStatus; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.time.LocalDate; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class EpicTest { - - @PersistenceContext - private EntityManager em; - - private Project project; - private Member member1; - private Member member2; - - @BeforeEach - void setUp() { - project = Project.builder() - .name("프로젝트1") - .key("PROJECT_1") - .build(); - - em.persist(project); - - member1 = Member.builder() - .name("멤버1") - .profileImageUrl("https://naver.com") - .status(MemberStatus.ACTIVE) - .build(); - - member2 = Member.builder() - .name("멤버2") - .build(); - - em.persist(member1); - em.persist(member2); - - MemberProject memberProject1 = MemberProject.builder() - .member(member1) - .project(project) - .role(MemberProjectRole.ADMIN) - .build(); - - MemberProject memberProject2 = MemberProject.builder() - .member(member2) - .project(project) - .role(MemberProjectRole.VIEWER) - .build(); - - em.persist(memberProject1); - em.persist(memberProject2); - - - } - - @Test - @Transactional - void 에픽_생성() { - // given - - Epic epic = Epic.builder() - .title("에픽1") - .content("에픽1 내용") - .number("") - .status(IssueStatus.DO) - .startDate(LocalDate.of(2024, 1, 1)) - .endDate(LocalDate.of(2024, 1, 10)) - .assignee(member1) - .project(project) - .build(); - - // when - em.persist(epic); - Epic findEpic = em.find(Epic.class, epic.getId()); - Long projectId = findEpic.getProject().getId(); - - // then - assertThat(findEpic).isEqualTo(epic); - assertThat(project.getId()).isEqualTo(projectId); - assertThat(findEpic.getAssignee()).isEqualTo(member1); - - - } - - -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java index 1b22231..4acfef2 100644 --- a/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/domain/IssueRepositoryTest.java @@ -2,112 +2,186 @@ import static org.assertj.core.api.Assertions.assertThat; +import dynamicquad.agilehub.config.MySQLTestContainer; +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; import dynamicquad.agilehub.issue.repository.IssueRepository; import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.sprint.domain.Sprint; import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; +import java.time.LocalDate; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.context.jdbc.Sql; -@ActiveProfiles("test") +@Sql(scripts = "/sql/test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) @SpringBootTest +@ExtendWith(MySQLTestContainer.class) // MySQL 컨테이너 적용 +@ActiveProfiles("test") // test 환경 적용 class IssueRepositoryTest { - @PersistenceContext - EntityManager em; - @Autowired private IssueRepository issueRepository; + @Autowired + EntityManager entityManager; + + @Test + void 이슈_타입_조회_테스트() { + // when + Optional issueType = issueRepository.findIssueTypeById(1L); + + // then + assertThat(issueType).isPresent(); + assertThat(issueType.get()).isEqualTo("EPIC"); + } + + @Test + void 특정_프로젝트에_이슈가_존재하는지_테스트() { + // when + boolean exists = issueRepository.existsByProjectIdAndId(1L, 1L); + + // then + assertThat(exists).isTrue(); + } @Test - @Transactional - void 스토리이슈의_타입을_조회하면_story_string을_반환한다() { + void 특정_스프린트에_속한_이슈_조회_테스트() { // given - Project project1 = createProject("프로젝트1", "project11214"); - em.persist(project1); + Sprint sprint = entityManager.getReference(Sprint.class, 1L); - Story story1P1 = createStory("스토리1", "스토리1 내용", project1); - em.persist(story1P1); + // when + List issues = issueRepository.findBySprint(sprint); + + // then + assertThat(issues).isNotEmpty(); + } + @Test + void 특정_에픽에_속한_스토리_조회_테스트() { // when - String issueType = issueRepository.findIssueTypeById(story1P1.getId()) - .orElseThrow(() -> new IllegalArgumentException("이슈가 없습니다.")); + List stories = issueRepository.findStoriesByEpicId(1L); + // then - assertThat(issueType).isEqualTo("STORY"); + assertThat(stories).isNotEmpty(); + assertThat(stories.get(0).getIssueType()).isEqualTo(IssueType.STORY); + } + @Test + void 특정_스토리에_속한_태스크_조회_테스트() { + // when + List tasks = issueRepository.findTasksByStoryId(2L); + + // then + assertThat(tasks).isNotEmpty(); + assertThat(tasks.get(0).getIssueType()).isEqualTo(IssueType.TASK); } @Test - @Transactional - void 없는_이슈를_타입조회하면_빈_Optional을_반환한다() { + void 특정_프로젝트의_에픽_조회_테스트() { // given + Project project = entityManager.getReference(Project.class, 1L); + // when - Optional issueType = issueRepository.findIssueTypeById(1L); + List epics = issueRepository.findEpicsByProject(project); + // then - assertThat(issueType).isEmpty(); + assertThat(epics).isNotEmpty(); + assertThat(epics.get(0).getIssueType()).isEqualTo(IssueType.EPIC); } @Test - @Transactional - void 프로젝트에_소속된_이슈들을_조회한다() { + void 특정_프로젝트의_스토리_조회_테스트() { // given - Project project1 = createProject("프로젝트1", "project1231"); - em.persist(project1); + Project project = entityManager.getReference(Project.class, 1L); - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); + // when + List stories = issueRepository.findStoriesByProject(project); - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); + // then + assertThat(stories).isNotEmpty(); + assertThat(stories.get(0).getIssueType()).isEqualTo(IssueType.STORY); + } - Story story1P1 = createStory("스토리1", "스토리1 내용", project1); - em.persist(story1P1); + @Test + void 특정_프로젝트의_태스크_조회_테스트() { + // given + Project project = entityManager.getReference(Project.class, 1L); // when - List byProject = issueRepository.findByProject(project1); + List tasks = issueRepository.findTasksByProject(project); // then - assertThat(byProject).hasSize(3); - assertThat(byProject.get(0).getTitle()).isEqualTo("에픽1"); - assertThat(byProject.get(1).getTitle()).isEqualTo("에픽2"); - assertThat(byProject.get(2).getTitle()).isEqualTo("스토리1"); + assertThat(tasks).isNotEmpty(); + assertThat(tasks.get(0).getIssueType()).isEqualTo(IssueType.TASK); } - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); + @Test + void 특정_날짜_범위_내_에픽_내용_조회_테스트() { + // given + LocalDate startDate = LocalDate.of(2024, 3, 1); + LocalDate endDate = LocalDate.of(2024, 3, 15); + + // when + List contents = issueRepository.findEpicContentsByMonth(startDate, endDate, 1L); + + // then + assertThat(contents).isNotEmpty(); } - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .project(project) - .build(); + @Test + void 특정_날짜_범위_내_스토리_내용_조회_테스트() { + // given + LocalDate startDate = LocalDate.of(2024, 3, 1); + LocalDate endDate = LocalDate.of(2024, 3, 10); + + // when + List contents = issueRepository.findStoryContentsByMonth(startDate, endDate, 1L); + + // then + assertThat(contents).isNotEmpty(); } - private Story createStory(String title, String content, Project project) { - return Story.builder() - .title(title) - .content(content) - .project(project) - .build(); + @Test + void 특정_날짜_범위_내_태스크_내용_조회_테스트() { + // given + LocalDate startDate = LocalDate.of(2024, 3, 3); + LocalDate endDate = LocalDate.of(2024, 3, 7); + + // when + List contents = issueRepository.findTaskContentsByMonth(startDate, endDate, 1L); + + // then + assertThat(contents).isNotEmpty(); } - private Task createTask(String title, String content, Project project) { - return Task.builder() - .title(title) - .content(content) - .project(project) - .build(); + @Test + @Sql(scripts = "/sql/epic-statistics-test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + @Sql(scripts = "/sql/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) + void 에픽_통계_기본_테스트() { + // given + Long projectId = 1L; + + // when + List statistics = issueRepository.getEpicStatics(projectId); + + // then + assertThat(statistics).isNotEmpty(); + + // 각 에픽 통계 검증 + for (EpicResponseDto.EpicStatistic statistic : statistics) { + assertThat(statistic.getEpicId()).isNotNull(); + assertThat(statistic.getStoriesCount()).isNotNull(); + // 상태별 카운트의 합이 전체 스토리 수와 일치해야 함 + assertThat(statistic.getStatusDo() + statistic.getStatusProgress() + statistic.getStatusDone()) + .isEqualTo(statistic.getStoriesCount()); + } } } \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/IssueTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/IssueTest.java new file mode 100644 index 0000000..4f40b28 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/domain/IssueTest.java @@ -0,0 +1,5 @@ +package dynamicquad.agilehub.issue.domain; + +class IssueTest { + +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java deleted file mode 100644 index c18c442..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/domain/StoryTest.java +++ /dev/null @@ -1,110 +0,0 @@ -package dynamicquad.agilehub.issue.domain; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.domain.MemberStatus; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.time.LocalDate; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class StoryTest { - - @PersistenceContext - private EntityManager em; - - private Project project; - private Member member1; - private Member member2; - - @BeforeEach - void setUp() { - project = Project.builder() - .name("프로젝트1") - .key("PROJECT_1") - .build(); - - em.persist(project); - - member1 = Member.builder() - .name("멤버1") - .profileImageUrl("https://naver.com") - .status(MemberStatus.ACTIVE) - .build(); - - member2 = Member.builder() - .name("멤버2") - .build(); - - em.persist(member1); - em.persist(member2); - - MemberProject memberProject1 = MemberProject.builder() - .member(member1) - .project(project) - .role(MemberProjectRole.ADMIN) - .build(); - - MemberProject memberProject2 = MemberProject.builder() - .member(member2) - .project(project) - .role(MemberProjectRole.VIEWER) - .build(); - - em.persist(memberProject1); - em.persist(memberProject2); - - - } - - @Test - @Transactional - void 스토리생성후_상위에픽_조회() { - // given - Epic epic = Epic.builder() - .title("에픽1") - .content("에픽1 내용") - .number("") - .status(IssueStatus.DO) - .assignee(member1) - .project(project) - .startDate(LocalDate.now()) - .endDate(LocalDate.now().plusDays(8)) - .build(); - - em.persist(epic); - - Story story = Story.builder() - .title("스토리1") - .content("스토리1 내용") - .number("") - .status(IssueStatus.PROGRESS) - .assignee(member1) - .project(project) - .storyPoint(5) - .startDate(LocalDate.now()) - .endDate(LocalDate.now().plusDays(7)) - .epic(epic) - .build(); - - em.persist(story); - - // when - Story findStory = em.find(Story.class, story.getId()); - Epic findEpic = findStory.getEpic(); - - // then - assertThat(findEpic).isEqualTo(epic); - } - -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/domain/epic/EpicRepositoryTest.java b/src/test/java/dynamicquad/agilehub/issue/domain/epic/EpicRepositoryTest.java deleted file mode 100644 index f1057d7..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/domain/epic/EpicRepositoryTest.java +++ /dev/null @@ -1,107 +0,0 @@ -package dynamicquad.agilehub.issue.domain.epic; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; -import dynamicquad.agilehub.issue.repository.EpicRepository; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class EpicRepositoryTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private EpicRepository epicRepository; - - @Test - @Transactional - void 프로젝트에_소속된_에픽이슈들을_조회한다() { - // Given - Project project1 = createProject("프로젝트1", "projec123t1"); - em.persist(project1); - - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); - // When - List byProject = epicRepository.findByProject(project1); - // Then - assertThat(byProject).hasSize(2); - assertThat(byProject).containsExactlyInAnyOrder(epic1P1, epic2P1); - } - - @Test - @Transactional - void 에픽에_있는_스토리_통계_정상적으로_가져오는지_확인() { - // Given - Project project1 = createProject("프로젝트1", "projec123t1"); - em.persist(project1); - - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); - - Story story1 = getStory(epic1P1, IssueStatus.DO, "스토리1", "스토리1 내용", project1); - em.persist(story1); - Story story2 = getStory(epic1P1, IssueStatus.PROGRESS, "스토리2", "스토리2 내용", project1); - em.persist(story2); - Story story3 = getStory(epic2P1, IssueStatus.DONE, "스토리3", "스토리3 내용", project1); - em.persist(story3); - - em.flush(); - em.clear(); - // When - List epicStatics = epicRepository.getEpicStatics(project1.getId()); - // Then - assertThat(epicStatics).hasSize(2); - assertThat(epicStatics.get(0).getStoriesCount()).isEqualTo(2); - assertThat(epicStatics.get(0).getStatusDo()).isEqualTo(1); - assertThat(epicStatics.get(0).getStatusProgress()).isEqualTo(1); - assertThat(epicStatics.get(0).getStatusDone()).isEqualTo(0); - - } - - private Story getStory(Epic epic1P1, IssueStatus status, String title, String content, Project project) { - return Story.builder() - .title(title) - .content(content) - .status(status) - .project(project) - .epic(epic1P1) - .build(); - } - - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .project(project) - .build(); - } - -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/ImageServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/ImageServiceTest.java deleted file mode 100644 index 2a40c23..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/ImageServiceTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package dynamicquad.agilehub.issue.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.when; - -import dynamicquad.agilehub.global.util.PhotoS3Manager; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Image; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.service.command.ImageService; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; - -@ActiveProfiles("test") -@SpringBootTest -class ImageServiceTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private ImageService imageService; - - @MockBean - private PhotoS3Manager photoS3Manager; - - @Test - @Transactional - void 이슈에_등록한_이미지두개를_정상적으로_저장() { - //given - Project project1 = createProject("프로젝트1", "project1"); - em.persist(project1); - - MultipartFile file1 = new MockMultipartFile("file1", "file1.jpg", MediaType.IMAGE_PNG_VALUE, - "file1".getBytes()); - MultipartFile file2 = new MockMultipartFile("file2", "file2.jpg", MediaType.IMAGE_PNG_VALUE, - "file2".getBytes()); - List files = List.of(file1, file2); - - Issue issue = Epic.builder() - .title("이슈1") - .content("이슈1 내용") - .status(IssueStatus.DO) - .project(project1) - .build(); - em.persist(issue); - - when(photoS3Manager.uploadPhotos(files, "/issue")).thenReturn(List.of("https://file.jpg", "https://file2.jpg")); - - //when - imageService.saveImages(issue, files, "/issue"); - //then - List images = em.createQuery("select i from Image i where i.issue = :issue", Image.class) - .setParameter("issue", em.find(Epic.class, issue.getId())) - .getResultList(); - - assertThat(images).hasSize(2); - assertThat(images.get(0).getPath()).isNotNull(); - assertThat(images.get(1).getPath()).isEqualTo("https://file2.jpg"); - - - } - - - @Test - @Transactional - void 기존에_저장했던_이미지_제거() { - //given - Image image1 = Image.builder() - .path("https://file1.jpg") - .build(); - em.persist(image1); - - Image image2 = Image.builder() - .path("https://file2.jpg") - .build(); - em.persist(image2); - - Epic epic = Epic.builder() - .title("이슈1") - .content("이슈1 내용") - .status(IssueStatus.DO) - .build(); - em.persist(epic); - - image1.setIssue(epic); - image2.setIssue(epic); - - List files = List.of(image1.getPath()); - when(photoS3Manager.deletePhotos(files, "/issue")).thenReturn(true); - - //when - imageService.cleanupMismatchedImages(epic, files, "/issue"); - - //then - em.flush(); - em.clear(); - assertThat(em.find(Image.class, image2.getId())).isNull(); - assertThat(em.find(Image.class, image1.getId())).isNotNull(); - } - - @Test - @Transactional - void 이미지링크를_안넘겼을때_모두_삭제() { - //given - Image image1 = Image.builder() - .path("https://file1.jpg") - .build(); - em.persist(image1); - - Image image2 = Image.builder() - .path("https://file2.jpg") - .build(); - em.persist(image2); - - Image image3 = Image.builder() - .path("https://file2.jpg") - .build(); - em.persist(image3); - - Epic epic = Epic.builder() - .title("이슈1") - .content("이슈1 내용") - .status(IssueStatus.DO) - .build(); - em.persist(epic); - - image1.setIssue(epic); - image2.setIssue(epic); - image3.setIssue(epic); - - List files = null; - when(photoS3Manager.deletePhotos(files, "/issue")).thenReturn(true); - - //when - imageService.cleanupMismatchedImages(epic, files, "/issue"); - - //then - em.flush(); - em.clear(); - assertThat(em.find(Image.class, image2.getId())).isNull(); - assertThat(em.find(Image.class, image1.getId())).isNull(); - assertThat(em.find(Image.class, image3.getId())).isNull(); - } - - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java deleted file mode 100644 index b6fb4c5..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/IssueUpdateConcurrencyTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package dynamicquad.agilehub.issue.service; - -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.IssueLabel; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.repository.IssueRepository; -import dynamicquad.agilehub.issue.service.command.IssueService; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; -import dynamicquad.agilehub.member.repository.MemberRepository; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRepository; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import dynamicquad.agilehub.project.domain.ProjectRepository; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.concurrent.CompletableFuture; -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.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; - -@SpringBootTest -@ActiveProfiles("test") -public class IssueUpdateConcurrencyTest { - - @Autowired - private IssueService issueService; - - @Autowired - private ProjectRepository projectRepository; - @Autowired - private MemberProjectRepository memberProjectRepository; - @Autowired - private MemberRepository memberRepository; - - @Autowired - private IssueRepository issueRepository; - - Project testProject; - ArrayList authMembers; - - @BeforeEach - void init() { - testProject = createTestProject(); - projectRepository.save(testProject); - System.out.println( - "잘 저장 = " + projectRepository.findByKey(testProject.getKey()).orElseThrow()); - authMembers = new ArrayList<>(); - } - - @Test - @DisplayName("동시에 2명의 사용자가 하나의 이슈에 편집을 했을 때") - void concurrentUpdateTest() { - // given - createMembers(2); - Issue testIssue = createTestIssue(); - - // when - CompletableFuture userA = CompletableFuture.runAsync(() -> { - issueService.updateIssue(testProject.getKey(), testIssue.getId(), - createEditIssueByEditContent("사용자 A가 수정한 내용"), authMembers.get(0)); - }).exceptionally(e -> { - Throwable cause = e.getCause(); - System.out.println("발생한 예외: " + cause.getClass().getName()); - System.out.println("예외 메시지: " + cause.getMessage()); - //e.printStackTrace(); - System.out.println("USER A 에러 발생"); - return null; - }); - CompletableFuture userB = CompletableFuture.runAsync(() -> { - - issueService.updateIssue(testProject.getKey(), testIssue.getId(), - createEditIssueByEditContent("사용자 B가 수정한 내용"), authMembers.get(1)); - - }).exceptionally(e -> { - Throwable cause = e.getCause(); - System.out.println("발생한 예외: " + cause.getClass().getName()); - System.out.println("예외 메시지: " + cause.getMessage()); - System.out.println("USER B 에러 발생"); - - return null; - }); - - // 두 작업이 모두 완료될 때까지 대기 - CompletableFuture.allOf(userA, userB).join(); - - // Then - Issue updatedIssue = issueRepository.findById(testIssue.getId()).get(); - System.out.println("최종 내용: " + updatedIssue.getContent()); - } - - private void createMembers(int memberCount) { - for (int j = 0; j < memberCount; j++) { - Member member = Member.builder() - .name("사용자" + j) - .build(); - memberRepository.save(member); - memberProjectRepository.save(MemberProject.builder() - .member(member) - .project(testProject) - .role(MemberProjectRole.ADMIN) - .build()); - authMembers.add(AuthMember.builder() - .id(member.getId()) - .name(member.getName()) - .build()); - } - } - - private Project createTestProject() { - return Project.builder() - .name("테스트 프로젝트") - .key("TEST") - .build(); - } - - - private Issue createTestIssue() { - Issue testIssue = Epic.builder() - .title("테스트용 이슈") - .content( - """ -

테스트용 이슈

-

이슈 내용입니다.

- """ - ) - .number("") - .status(IssueStatus.DO) - .assignee(null) - .label(IssueLabel.TEST) - .project(testProject) - .startDate(LocalDate.of(2024, 11, 6)) - .endDate(LocalDate.of(2024, 11, 9)) - .build(); - - issueRepository.save(testIssue); - return testIssue; - } - - private IssueRequestDto.EditIssue createEditIssueByEditContent(String editContent) { - return IssueRequestDto.EditIssue.builder() - .title("테스트용 이슈") - .content(editContent) - .type(IssueType.EPIC) - .status(IssueStatus.DO) - .label(IssueLabel.TEST) - .startDate(LocalDate.of(2024, 11, 6)) - .endDate(LocalDate.of(2024, 11, 9)) - .build(); - } -} diff --git a/src/test/java/dynamicquad/agilehub/issue/service/IssueValidatorTest.java b/src/test/java/dynamicquad/agilehub/issue/service/IssueValidatorTest.java new file mode 100644 index 0000000..874afb7 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/service/IssueValidatorTest.java @@ -0,0 +1,170 @@ +package dynamicquad.agilehub.issue.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import dynamicquad.agilehub.global.exception.GeneralException; +import dynamicquad.agilehub.global.header.status.ErrorStatus; +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.testSetUp.TestDataSetup; +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.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class IssueValidatorTest { + + @Autowired + private IssueValidator issueValidator; + + @Autowired + private IssueRepository issueRepository; + + @Autowired + private TestDataSetup testDataSetup; + + private Project project; + private Member member; + private Issue epicIssue; + private Issue storyIssue; + private Issue taskIssue; + + @BeforeEach + void setUp() { + // 테스트 데이터 설정 + project = testDataSetup.createAndSaveProject("테스트 프로젝트", "TEST"); + member = testDataSetup.createAndSaveMember("테스트 사용자", MemberStatus.ACTIVE); + testDataSetup.createAndSaveMemberProject(project, member, MemberProjectRole.ADMIN); + + // Epic 이슈 생성 + IssueRequestDto.CreateIssue epicRequest = IssueRequestDto.CreateIssue.builder() + .title("에픽 이슈") + .content("에픽 내용") + .status(IssueStatus.DO) + .type(IssueType.EPIC) + .build(); + epicIssue = Issue.createEpic(epicRequest, member, "TEST-1", project); + issueRepository.save(epicIssue); + + // Story 이슈 생성 + IssueRequestDto.CreateIssue storyRequest = IssueRequestDto.CreateIssue.builder() + .title("스토리 이슈") + .content("스토리 내용") + .status(IssueStatus.DO) + .type(IssueType.STORY) + .parentId(epicIssue.getId()) + .build(); + storyIssue = Issue.createStory(storyRequest, member, "TEST-2", project); + issueRepository.save(storyIssue); + + // Task 이슈 생성 + IssueRequestDto.CreateIssue taskRequest = IssueRequestDto.CreateIssue.builder() + .title("태스크 이슈") + .content("태스크 내용") + .status(IssueStatus.DO) + .type(IssueType.TASK) + .parentId(storyIssue.getId()) + .build(); + taskIssue = Issue.createTask(taskRequest, member, "TEST-3", project); + issueRepository.save(taskIssue); + } + + @Test + @DisplayName("validateIssueInProject: 프로젝트에 이슈가 존재하는 경우 예외가 발생하지 않는다") + void validateIssueInProject_Success() { + // when & then + assertDoesNotThrow(() -> issueValidator.validateIssueInProject(project.getId(), epicIssue.getId())); + } + + @Test + @DisplayName("validateIssueInProject: 프로젝트에 이슈가 존재하지 않는 경우 예외가 발생한다") + void validateIssueInProject_Fail() { + // given + Long nonExistingProjectId = 9999L; + + // when & then + assertThatThrownBy(() -> issueValidator.validateIssueInProject(nonExistingProjectId, epicIssue.getId())) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus.ISSUE_NOT_IN_PROJECT); + } + + @Test + @DisplayName("findIssue: 존재하는 이슈를 찾을 수 있다") + void findIssue_Success() { + // when + Issue foundIssue = issueValidator.findIssue(epicIssue.getId()); + + // then + assertThat(foundIssue).isNotNull(); + assertThat(foundIssue.getId()).isEqualTo(epicIssue.getId()); + } + + @Test + @DisplayName("findIssue: 존재하지 않는 이슈를 찾으려고 하면 예외가 발생한다") + void findIssue_Fail() { + // given + Long nonExistingIssueId = 9999L; + + // when & then + assertThatThrownBy(() -> issueValidator.findIssue(nonExistingIssueId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus.ISSUE_NOT_FOUND); + } + + @Test + @DisplayName("validateEqualsIssueType: 이슈 타입이 일치하는 경우 예외가 발생하지 않는다") + void validateEqualsIssueType_Success() { + // when & then + assertDoesNotThrow(() -> issueValidator.validateEqualsIssueType(epicIssue, IssueType.EPIC)); + } + + @Test + @DisplayName("validateEqualsIssueType: 이슈 타입이 일치하지 않는 경우 예외가 발생한다") + void validateEqualsIssueType_Fail() { + // when & then + assertThatThrownBy(() -> issueValidator.validateEqualsIssueType(epicIssue, IssueType.STORY)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus.ISSUE_TYPE_MISMATCH); + } + + @Test + @DisplayName("getIssueType: 이슈 타입을 정확히 가져올 수 있다") + void getIssueType_Success() { + // when + IssueType epicType = issueValidator.getIssueType(epicIssue.getId()); + IssueType storyType = issueValidator.getIssueType(storyIssue.getId()); + IssueType taskType = issueValidator.getIssueType(taskIssue.getId()); + + // then + assertThat(epicType).isEqualTo(IssueType.EPIC); + assertThat(storyType).isEqualTo(IssueType.STORY); + assertThat(taskType).isEqualTo(IssueType.TASK); + } + + @Test + @DisplayName("getIssueType: 존재하지 않는 이슈의 타입을 가져오려고 하면 예외가 발생한다") + void getIssueType_Fail() { + // given + Long nonExistingIssueId = 9999L; + + // when & then + assertThatThrownBy(() -> issueValidator.getIssueType(nonExistingIssueId)) + .isInstanceOf(GeneralException.class) + .hasFieldOrPropertyWithValue("status", ErrorStatus.ISSUE_TYPE_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/command/IssueServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/command/IssueServiceTest.java new file mode 100644 index 0000000..7015cb7 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/service/command/IssueServiceTest.java @@ -0,0 +1,202 @@ +package dynamicquad.agilehub.issue.service.command; + +import static org.assertj.core.api.Assertions.assertThat; + +import dynamicquad.agilehub.config.RedisTestContainer; +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueLabel; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; +import dynamicquad.agilehub.issue.dto.IssueRequestDto; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.CreateIssue; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.testSetUp.TestDataSetup; +import jakarta.persistence.EntityManager; +import java.time.LocalDate; +import java.util.ArrayList; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@ExtendWith(RedisTestContainer.class) +class IssueServiceTest { + + @Autowired + private IssueService issueService; + + @Autowired + private TestDataSetup testDataSetup; + + @Autowired + private EntityManager em; + + private Project testProject; + private Issue testEpic; + private List testStoriesInTestEpic = new ArrayList<>(); + private Issue testStory; + private Member member; + + @BeforeEach + void setUp() { + + testProject = testDataSetup.createAndSaveProject("testProject", "TP"); + member = testDataSetup.createAndSaveMember("testMember", MemberStatus.ACTIVE); + MemberProject memberProject = testDataSetup.createAndSaveMemberProject(testProject, member, + MemberProjectRole.ADMIN); + + testEpic = testDataSetup.createIssue(testProject, "테스트 에픽", IssueType.EPIC, member); + em.persist(testEpic); + + testStory = testDataSetup.createIssue(testProject, "테스트 스토리", IssueType.STORY, member); + testStoriesInTestEpic.add(testStory); + Issue testStory2 = testDataSetup.createIssue(testProject, "테스트 스토리", IssueType.STORY, member); + testStoriesInTestEpic.add(testStory2); + + testStory.setParentIssue(testEpic); + testStory2.setParentIssue(testEpic); + + em.persist(testStory); + em.persist(testStory2); + em.flush(); + + } + + @Test + @DisplayName("이슈를 정상적으로 생성해야 한다") + void createIssueTest() { + + // given + + CreateIssue createRequest = createIssue("테스트 이슈", "설명", IssueStatus.DO, IssueLabel.TEST); + + // when + Long issueId = issueService.createIssue(testProject.getKey(), createRequest, + AuthMember.builder().id(member.getId()).build()); + + // then + Issue createdIssue = em.find(Issue.class, issueId); + + assertThat(createdIssue.getTitle()).isEqualTo("테스트 이슈"); + assertThat(createdIssue.getIssueType()).isEqualTo(IssueType.EPIC); + assertThat(createdIssue.getStatus()).isEqualTo(IssueStatus.DO); + assertThat(createdIssue.getLabel()).isEqualTo(IssueLabel.TEST); + assertThat(createdIssue.getContent()).isEqualTo("설명"); + assertThat(createdIssue.getAssignee().getId()).isEqualTo(member.getId()); + } + + @Test + @DisplayName("이슈 상태를 변경할 수 있어야 한다") + void updateIssueStatusTest() { + // given + IssueStatus newStatus = IssueStatus.PROGRESS; + + // when + issueService.updateIssueStatus(testProject.getKey(), testStory.getId(), + AuthMember.builder().id(member.getId()).build(), newStatus); + + // then + Issue updatedIssue = em.find(Issue.class, testStory.getId()); + assertThat(updatedIssue.getStatus()).isEqualTo(newStatus); + } + + @Test + @DisplayName("이슈를 정상적으로 삭제해야 한다") + void deleteIssueTest() { + // given + // 프로젝트 생성을 ProjectService에서 처리해야 이슈시퀀스테이블이 업데이트됨 따라서 강제 업데이트하자 + em.persist(new ProjectIssueSequence(testProject.getKey())); + em.flush(); + + CreateIssue createRequest = createIssue("삭제 이슈", "설명", IssueStatus.DO, IssueLabel.TEST); + Long issueId = issueService.createIssue(testProject.getKey(), createRequest, + AuthMember.builder().id(member.getId()).build()); + + // when + issueService.deleteIssue(testProject.getKey(), issueId, AuthMember.builder().id(member.getId()).build()); + + // then + Issue deletedIssue = em.find(Issue.class, issueId); + assertThat(deletedIssue).isNull(); + } + + @Test + @DisplayName("이슈를 정상적으로 수정할 수 있어야 한다") + void updateIssueTest() { + // given + Issue beforeIssue = testDataSetup.createIssue(testProject, "수정 전 이슈", IssueType.EPIC, member); + em.persist(beforeIssue); + + IssueRequestDto.EditIssue updateRequest = IssueRequestDto.EditIssue.builder() + .title("수정된 이슈") + .content("수정된 설명") + .status(IssueStatus.PROGRESS) + .label(IssueLabel.DESIGN) + .type(IssueType.EPIC) + .build(); + + // when + issueService.updateIssue(testProject.getKey(), beforeIssue.getId(), updateRequest, + AuthMember.builder().id(member.getId()).build()); + + // then + Issue updatedIssue = em.find(Issue.class, beforeIssue.getId()); + assertThat(updatedIssue.getTitle()).isEqualTo("수정된 이슈"); + assertThat(updatedIssue.getContent()).isEqualTo("수정된 설명"); + assertThat(updatedIssue.getStatus()).isEqualTo(IssueStatus.PROGRESS); + assertThat(updatedIssue.getLabel()).isEqualTo(IssueLabel.DESIGN); + } + + + @Test + @DisplayName("이슈 기간을 정상적으로 수정할 수 있어야 한다") + void updateIssuePeriodTest() { + // given + Issue beforeIssue = testDataSetup.createIssue(testProject, "기간 수정 전 이슈", IssueType.EPIC, member); + beforeIssue.updatePeriod(LocalDate.of(2024, 3, 3), LocalDate.of(2024, 3, 5)); + em.persist(beforeIssue); + + IssueRequestDto.EditIssuePeriod editPeriodRequest = new IssueRequestDto.EditIssuePeriod( + LocalDate.of(2024, 3, 1), + LocalDate.of(2024, 3, 10)); + + // when + issueService.updateIssuePeriod(testProject.getKey(), beforeIssue.getId(), + AuthMember.builder().id(member.getId()).build(), + editPeriodRequest); + Issue updatedIssue = em.find(Issue.class, beforeIssue.getId()); + + // then + assertThat(updatedIssue.getStartDate()).isEqualTo("2024-03-01"); + assertThat(updatedIssue.getEndDate()).isEqualTo("2024-03-10"); + } + + private CreateIssue createIssue(String title, String content, IssueStatus status, IssueLabel label) { + return CreateIssue.builder() + .title(title) + .content(content) + .status(status) + .label(label) + .files(new ArrayList<>()) + .startDate(null) + .endDate(null) + .assigneeId(member.getId()) + .type(IssueType.EPIC) + .parentId(null) + .build(); + } +} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java deleted file mode 100644 index caa293f..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/EpicFactoryTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.config.RedisTestContainer; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Issue; -import dynamicquad.agilehub.issue.domain.IssueLabel; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.repository.IssueRepository; -import dynamicquad.agilehub.member.domain.Member; -import dynamicquad.agilehub.member.domain.MemberStatus; -import dynamicquad.agilehub.member.repository.MemberRepository; -import dynamicquad.agilehub.project.domain.MemberProject; -import dynamicquad.agilehub.project.domain.MemberProjectRepository; -import dynamicquad.agilehub.project.domain.MemberProjectRole; -import dynamicquad.agilehub.project.domain.Project; -import dynamicquad.agilehub.project.domain.ProjectRepository; -import java.time.LocalDate; -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.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -@Transactional -@ExtendWith(RedisTestContainer.class) -class EpicFactoryTest { - - @Autowired - private EpicFactory epicFactory; - - @Autowired - private IssueRepository issueRepository; - - @Autowired - private ProjectRepository projectRepository; - - @Autowired - private MemberRepository memberRepository; - - @Autowired - private MemberProjectRepository memberProjectRepository; - - private Project project; - private Member member; - private MemberProject memberProject; - - @BeforeEach - void setUp() { - project = Project.builder() - .key("PT") - .name("PROJECT_TEST") - .build(); - - projectRepository.save(project); - - member = Member.builder() - .name("Test Member") - .status(MemberStatus.ACTIVE) - .profileImageUrl("www.test.com") - .build(); - - memberRepository.save(member); - - memberProject = MemberProject.builder() - .member(member) - .project(project) - .role(MemberProjectRole.ADMIN) - .build(); - memberProjectRepository.save(memberProject); - } - - @Test - @DisplayName("이미지가 없는 Epic 이슈를 생성한다") - void createEpicIssueWithoutImages() { - // given - IssueRequestDto.CreateIssue request = IssueRequestDto.CreateIssue - .builder() - .title("테스트 에픽 이슈") - .label(IssueLabel.TEST) - .files(null) - .startDate(LocalDate.of(2025, 2, 10)) - .endDate(LocalDate.of(2025, 2, 20)) - .parentId(null) - .assigneeId(member.getId()) - .content("Test Epic Content") - .build(); - - // when - Long epicId = epicFactory.createIssue(request, project); - - // then - Issue savedEpic = issueRepository.findById(epicId) - .orElseThrow(() -> new RuntimeException("Epic not found")); - - Assertions.assertAll( - () -> assertThat(savedEpic.getTitle()).isEqualTo("테스트 에픽 이슈"), - () -> assertThat(savedEpic.getContent()).isEqualTo("Test Epic Content"), - () -> assertThat(((Epic) savedEpic).getStartDate()).isEqualTo(LocalDate.of(2025, 2, 10)), - () -> assertThat(((Epic) savedEpic).getEndDate()).isEqualTo(LocalDate.of(2025, 2, 20)), - () -> assertThat(savedEpic.getAssignee().getId()).isEqualTo(member.getId()), - () -> assertThat(savedEpic.getProject().getId()).isEqualTo(project.getId()), - () -> assertThat(savedEpic.getNumber()).startsWith(project.getKey() + "-") - ); - } - - -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImplTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImplTest.java new file mode 100644 index 0000000..0e29f18 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/issue/service/factory/IssueFactoryImplTest.java @@ -0,0 +1,290 @@ +package dynamicquad.agilehub.issue.service.factory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueLabel; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.CreateIssue; +import dynamicquad.agilehub.issue.dto.IssueRequestDto.EditIssue; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.ContentDto; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.IssueDetail; +import dynamicquad.agilehub.issue.dto.IssueResponseDto.SubIssueDetail; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.issue.service.command.ImageService; +import dynamicquad.agilehub.issue.service.command.IssueNumberGenerator; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.member.dto.AssigneeDto; +import dynamicquad.agilehub.member.service.MemberService; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.testSetUp.TestDataSetup; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +public class IssueFactoryImplTest { + @Autowired + private IssueFactory issueFactory; + + @Autowired + private IssueRepository issueRepository; + + @MockBean + private ImageService imageService; + + @MockBean + private IssueNumberGenerator issueNumberGenerator; + + @MockBean + private MemberService memberService; + + @Autowired + private TestDataSetup testDataSetup; + + private Project testProject; + private Member testMember; + + + @BeforeEach + void setUp() { + // 각 테스트 실행 전에 기본 데이터 준비 + testProject = testDataSetup.createAndSaveProject("테스트 프로젝트", "TEST"); + testMember = testDataSetup.createAndSaveMember("테스트 멤버", MemberStatus.ACTIVE); + testDataSetup.createAndSaveMemberProject(testProject, testMember, MemberProjectRole.ADMIN); + } + + @Test + @DisplayName("이슈 생성 - EPIC 타입") + void createEpicIssue() { + // given + CreateIssue request = createIssueRequest(IssueType.EPIC); + + when(issueNumberGenerator.generate(anyString())).thenReturn("TEST-1"); + when(memberService.findMember(anyLong(), anyLong())).thenReturn(testMember); + + // when + Long issueId = issueFactory.createIssue(request, testProject); + + // then + Issue savedIssue = issueRepository.findById(issueId).orElseThrow(); + assertThat(savedIssue).isNotNull(); + assertThat(savedIssue.getIssueType()).isEqualTo(IssueType.EPIC); + assertThat(savedIssue.getNumber()).isEqualTo("TEST-1"); + assertThat(savedIssue.getContent()).isEqualTo("테스트 내용"); + assertThat(savedIssue.getTitle()).isEqualTo("테스트 제목"); + } + + @Test + @DisplayName("이슈 생성 - STORY 타입") + void createStoryIssue() { + // given + CreateIssue request = createIssueRequest(IssueType.STORY); + + when(issueNumberGenerator.generate(anyString())).thenReturn("TEST-2"); + when(memberService.findMember(anyLong(), anyLong())).thenReturn(testMember); + + // when + Long issueId = issueFactory.createIssue(request, testProject); + + // then + Issue savedIssue = issueRepository.findById(issueId).orElseThrow(); + assertThat(savedIssue).isNotNull(); + assertThat(savedIssue.getIssueType()).isEqualTo(IssueType.STORY); + assertThat(savedIssue.getNumber()).isEqualTo("TEST-2"); + } + + @Test + @DisplayName("이슈 업데이트") + void updateIssue() { + // given + CreateIssue createRequest = createIssueRequest(IssueType.TASK); + + when(issueNumberGenerator.generate(anyString())).thenReturn("TEST-3"); + when(memberService.findMember(anyLong(), anyLong())).thenReturn(testMember); + + Long issueId = issueFactory.createIssue(createRequest, testProject); + Issue issue = issueRepository.findById(issueId).orElseThrow(); + + EditIssue editRequest = EditIssue.builder() + .title("수정된 제목") + .content("수정된 내용") + .assigneeId(2L) + .type(IssueType.TASK) + .status(IssueStatus.PROGRESS) + .label(IssueLabel.DEVELOP) + .startDate(LocalDate.now().plusDays(1)) + .endDate(LocalDate.now().plusDays(5)) + .imageUrls(new ArrayList<>()) + .build(); + + Member newAssignee = Mockito.mock(Member.class); + when(newAssignee.getId()).thenReturn(2L); + when(newAssignee.getName()).thenReturn("새 담당자"); + + when(memberService.findMember(anyLong(), anyLong())).thenReturn(newAssignee); + + // when + Long updatedIssueId = issueFactory.updateIssue(issue, testProject, editRequest); + + // then + Issue updatedIssue = issueRepository.findById(updatedIssueId).orElseThrow(); + assertThat(updatedIssue.getTitle()).isEqualTo("수정된 제목"); + assertThat(updatedIssue.getContent()).isEqualTo("수정된 내용"); + assertThat(updatedIssue.getAssignee().getId()).isEqualTo(2L); + } + + @Test + @DisplayName("ContentDto 생성") + void createContentDto() { + // given + Issue issue = Issue.createTask( + createIssueRequest(IssueType.TASK), + testMember, + "TEST-4", + testProject); + issueRepository.save(issue); + + // when + ContentDto contentDto = issueFactory.createContentDto(issue); + + // then + assertThat(contentDto).isNotNull(); + assertThat(contentDto.getText()).isEqualTo("테스트 내용"); + } + + @Test + @DisplayName("IssueDetail 생성") + void createIssueDetail() { + // given + Issue issue = Issue.createTask( + createIssueRequest(IssueType.TASK), + testMember, + "TEST-5", + testProject); + issueRepository.save(issue); + + ContentDto contentDto = ContentDto.from(issue); + AssigneeDto assigneeDto = AssigneeDto.from(issue); + + // when + IssueDetail issueDetail = issueFactory.createIssueDetail(issue, contentDto, assigneeDto); + + // then + assertThat(issueDetail).isNotNull(); + assertThat(issueDetail.getTitle()).isEqualTo("테스트 제목"); + assertThat(issueDetail.getKey()).isEqualTo("TEST-5"); + assertThat(issueDetail.getType()).isEqualTo("TASK"); + assertThat(issueDetail.getContent().getText()).isEqualTo("테스트 내용"); + assertThat(issueDetail.getAssignee().getName()).isEqualTo("테스트 멤버"); + } + + @Test + @DisplayName("상위 이슈 생성 - 부모 이슈가 없는 경우(에픽)") + void createParentIssueByEpic() { + // given + Issue parentIssue = Issue.createEpic( + createIssueRequest(IssueType.EPIC), + testMember, + "TEST-6", + testProject); + issueRepository.save(parentIssue); + + // when + SubIssueDetail parentIssueDto = issueFactory.createParentIssue(parentIssue); + + // then + assertThat(parentIssueDto).isNotNull(); + assertThat(parentIssueDto.getKey()).isEqualTo(""); + } + + @Test + @DisplayName("상위 이슈 생성 - 부모 이슈가 있는 경우(스토리)") + void createParentIssueByStory() { + // given + Issue parentIssue = Issue.createEpic( + createIssueRequest(IssueType.EPIC), + testMember, + "TEST-6", + testProject); + issueRepository.save(parentIssue); + + CreateIssue issueRequest = createIssueRequest(IssueType.STORY); + issueRequest.setParentId(parentIssue.getId()); + Issue subIssue = Issue.createStory( + issueRequest, + testMember, + "TEST-7", + testProject); + issueRepository.save(subIssue); + + // when + SubIssueDetail parentIssueDto = issueFactory.createParentIssue(subIssue); + + // then + assertThat(parentIssueDto).isNotNull(); + assertThat(parentIssueDto.getKey()).isEqualTo("TEST-6"); + } + + @Test + @DisplayName("하위 이슈 목록 생성") + void createChildIssueDtos() { + // given + // 부모 이슈 생성 + Issue parentIssue = Issue.createEpic( + createIssueRequest(IssueType.EPIC), + testMember, + "TEST-6", + testProject); + issueRepository.save(parentIssue); + + // 자식 이슈 생성 + CreateIssue childRequest = createIssueRequest(IssueType.STORY); + childRequest.setParentId(parentIssue.getId()); + + Issue childIssue = Issue.createStory( + childRequest, + testMember, + "TEST-7", + testProject); + issueRepository.save(childIssue); + + // when + List childIssueDtos = issueFactory.createChildIssueDtos(parentIssue); + + // then + assertThat(childIssueDtos).isNotEmpty(); + assertThat(childIssueDtos.size()).isEqualTo(1); + assertThat(childIssueDtos.get(0).getKey()).isEqualTo("TEST-7"); + } + + private CreateIssue createIssueRequest(IssueType issueType) { + return CreateIssue.builder() + .title("테스트 제목") + .content("테스트 내용") + .type(issueType) + .status(IssueStatus.DO) + .label(IssueLabel.PLAN) + .assigneeId(1L) + .startDate(LocalDate.now()) + .endDate(LocalDate.now().plusDays(3)) + .build(); + } +} diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java deleted file mode 100644 index ce08476..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/StoryFactoryTest.java +++ /dev/null @@ -1,200 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dynamicquad.agilehub.config.RedisTestContainer; -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.dto.IssueResponseDto.SubIssueDetail; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -@Import(RedisTestContainer.class) -class StoryFactoryTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private StoryFactory storyFactory; - - - @Test - @Transactional - void 부모이슈가_스토리나_테스크일때_예외처리() { - // given - Project project = createProject("프로젝트1", "project1"); - em.persist(project); - - Story story = Story.builder() - .title("story제목") - .status(IssueStatus.DONE) - .build(); - - em.persist(story); - - Task task = Task.builder() - .title("task제목") - .status(IssueStatus.DONE) - .build(); - em.persist(task); - - Long parentIssueId1 = story.getId(); - Long parentIssueId2 = task.getId(); - - // when - IssueRequestDto.CreateIssue storyIssueRequest = IssueRequestDto.CreateIssue.builder() - .title("스토리 제목") - .type(IssueType.STORY) - .status(IssueStatus.DONE) - .parentId(parentIssueId1) - .build(); - - // then - assertThatThrownBy(() -> storyFactory.createIssue(storyIssueRequest, project)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus.PARENT_ISSUE_NOT_EPIC); - - // when - IssueRequestDto.CreateIssue storyIssueRequest2 = IssueRequestDto.CreateIssue.builder() - .title("스토리 제목") - .type(IssueType.STORY) - .status(IssueStatus.DONE) - .parentId(parentIssueId2) - .build(); - - assertThatThrownBy(() -> storyFactory.createIssue(storyIssueRequest2, project)) - .hasFieldOrPropertyWithValue("status", ErrorStatus.PARENT_ISSUE_NOT_EPIC); - - - } - - @Test - @Transactional - void 부모에픽을_정상적으로_찾는다() { - // given - Project project = createProject("프로젝트1", "pro1231ject1"); - em.persist(project); - Epic epic = Epic.builder() - .title("에픽 제목") - .status(IssueStatus.DO) - .build(); - em.persist(epic); - - // when - IssueRequestDto.CreateIssue storyIssueRequest = IssueRequestDto.CreateIssue.builder() - .title("스토리 제목") - .type(IssueType.STORY) - .status(IssueStatus.DO) - .parentId(epic.getId()) - .build(); - - // then - assertThat(storyFactory.retrieveEpicFromParentIssue(epic.getId())).isEqualTo(epic); - - } - - @Test - @Transactional - void 부모이슈가_정상적으로_등록() { - // given - Project project = createProject("프로젝트1", "project12451"); - em.persist(project); - Epic epic = Epic.builder() - .title("에픽 제목") - .status(IssueStatus.DO) - .build(); - em.persist(epic); - - // when - IssueRequestDto.CreateIssue storyIssueRequest = IssueRequestDto.CreateIssue.builder() - .title("스토리 제목") - .type(IssueType.STORY) - .status(IssueStatus.DO) - .parentId(epic.getId()) - .build(); - - Long issueId = storyFactory.createIssue(storyIssueRequest, project); - - // then - Story story = em.find(Story.class, issueId); - assertThat(story.getEpic().getTitle()).isEqualTo("에픽 제목"); - - - } - - @Test - @Transactional - void 하위_이슈들_정상적으로_가져오기() { - // given - Project project = createProject("프로젝트1", "project12411"); - em.persist(project); - Epic epic = Epic.builder() - .title("에픽 제목") - .project(project) - .status(IssueStatus.DO) - .build(); - em.persist(epic); - - Story story = Story.builder() - .project(project) - .title("스토리 제목") - .status(IssueStatus.DO) - .epic(epic) - .build(); - em.persist(story); - - Task task1 = Task.builder() - .project(project) - .title("task1") - .status(IssueStatus.DO) - .story(story) - .build(); - em.persist(task1); - - Task task2 = Task.builder() - .project(project) - .title("task2") - .status(IssueStatus.DO) - .story(story) - .build(); - em.persist(task2); - - // when - Story storyFromDB = em.find(Story.class, story.getId()); - List childIssueDtos = storyFactory.createChildIssueDtos(storyFromDB); - // then - assertThat(childIssueDtos).hasSize(2); - assertThat(childIssueDtos.get(0).getTitle()).isEqualTo("task1"); - assertThat(childIssueDtos.get(1).getTitle()).isEqualTo("task2"); - assertThat(childIssueDtos.get(0).getType()).isEqualTo("TASK"); - assertThat(childIssueDtos.get(0).getAssignee()).isNotNull(); - assertThat(childIssueDtos.get(0).getAssignee().getName()).isEqualTo(""); - - } - - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java b/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java deleted file mode 100644 index bf10ea4..0000000 --- a/src/test/java/dynamicquad/agilehub/issue/service/factory/TaskFactoryTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package dynamicquad.agilehub.issue.service.factory; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import dynamicquad.agilehub.config.RedisTestContainer; -import dynamicquad.agilehub.global.exception.GeneralException; -import dynamicquad.agilehub.global.header.status.ErrorStatus; -import dynamicquad.agilehub.issue.IssueType; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.ProjectIssueSequence; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.issue.dto.IssueRequestDto; -import dynamicquad.agilehub.issue.repository.ProjectIssueSequenceRepository; -import dynamicquad.agilehub.project.domain.Project; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.annotation.Import; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -@Import(RedisTestContainer.class) -class TaskFactoryTest { - - @PersistenceContext - EntityManager em; - - @Autowired - private TaskFactory taskFactory; - - @Autowired - private ProjectIssueSequenceRepository projectIssueSequenceRepository; - - @Test - @Transactional - void 부모이슈가_에픽이거나_테스크일때_예외처리() { - // given - Project project = createProject("프로젝트1", "project124151"); - em.persist(project); - - projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey())); - - Epic epic = Epic.builder() - .title("에픽제목") - .status(IssueStatus.DONE) - .build(); - em.persist(epic); - - Task task = Task.builder() - .title("task제목") - .status(IssueStatus.DONE) - .build(); - em.persist(task); - - Long parentIssueId1 = epic.getId(); - Long parentIssueId2 = task.getId(); - - // when - IssueRequestDto.CreateIssue taskIssueRequest = IssueRequestDto.CreateIssue.builder() - .title("테스크 제목") - .type(IssueType.TASK) - .status(IssueStatus.DONE) - .parentId(parentIssueId1) - .build(); - - // then - assertThatThrownBy(() -> taskFactory.createIssue(taskIssueRequest, project)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus.PARENT_ISSUE_NOT_STORY); - - // when - IssueRequestDto.CreateIssue taskIssueRequest1 = IssueRequestDto.CreateIssue.builder() - .title("테스크 제목") - .type(IssueType.TASK) - .status(IssueStatus.DONE) - .parentId(parentIssueId2) - .build(); - - // then - assertThatThrownBy(() -> taskFactory.createIssue(taskIssueRequest1, project)) - .isInstanceOf(GeneralException.class) - .hasFieldOrPropertyWithValue("status", ErrorStatus.PARENT_ISSUE_NOT_STORY); - } - - @Test - @Transactional - void 부모이슈를_정상적으로_등록() { - // given - Project project = createProject("프로젝트1", "proje123ct1"); - em.persist(project); - projectIssueSequenceRepository.save(new ProjectIssueSequence(project.getKey())); - - Story story = Story.builder() - .title("story제목") - .status(IssueStatus.DONE) - .build(); - em.persist(story); - - Long parentIssueId = story.getId(); - - // when - IssueRequestDto.CreateIssue taskIssueRequest = IssueRequestDto.CreateIssue.builder() - .title("테스크 제목") - .type(IssueType.TASK) - .status(IssueStatus.DONE) - .parentId(parentIssueId) - .build(); - - Long issueId = taskFactory.createIssue(taskIssueRequest, project); - - // then - Task task = em.find(Task.class, issueId); - assertThat(task.getStory().getId()).isEqualTo(story.getId()); - assertThat(task.getStory().getTitle()).isEqualTo(story.getTitle()); - - - } - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } -} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/issue/service/query/IssueQueryServiceTest.java b/src/test/java/dynamicquad/agilehub/issue/service/query/IssueQueryServiceTest.java index b5c495b..3b5229b 100644 --- a/src/test/java/dynamicquad/agilehub/issue/service/query/IssueQueryServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/issue/service/query/IssueQueryServiceTest.java @@ -1,14 +1,26 @@ package dynamicquad.agilehub.issue.service.query; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; +import static org.assertj.core.api.Java6Assertions.assertThat; -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.IssueStatus; -import dynamicquad.agilehub.issue.domain.Story; +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.dto.IssueResponseDto; +import dynamicquad.agilehub.issue.dto.backlog.EpicResponseDto; +import dynamicquad.agilehub.issue.dto.backlog.StoryResponseDto; +import dynamicquad.agilehub.issue.dto.backlog.TaskResponseDto; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.member.dto.MemberRequestDto.AuthMember; +import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRole; import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.testSetUp.TestDataSetup; import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -17,64 +29,288 @@ @ActiveProfiles("test") @SpringBootTest +@Transactional class IssueQueryServiceTest { @Autowired private IssueQueryService issueQueryService; - @PersistenceContext + @Autowired + private TestDataSetup testDataSetup; + + @Autowired private EntityManager em; + private Project testProject; + private Issue testEpic; + private List testStoriesInTestEpic = new ArrayList<>(); + private Issue testStory; + private Member member; + + private static final String PROJECT_KEY = "TP"; + + @BeforeEach + void setUp() { + testProject = testDataSetup.createAndSaveProject("testProject", "TP"); + member = testDataSetup.createAndSaveMember("testMember", MemberStatus.ACTIVE); + MemberProject memberProject = testDataSetup.createAndSaveMemberProject(testProject, member, + MemberProjectRole.ADMIN); + + testEpic = testDataSetup.createIssue(testProject, "테스트 에픽", IssueType.EPIC, member); + em.persist(testEpic); + + testStory = testDataSetup.createIssue(testProject, "테스트 스토리", IssueType.STORY, member); + testStoriesInTestEpic.add(testStory); + Issue testStory2 = testDataSetup.createIssue(testProject, "테스트 스토리", IssueType.STORY, member); + testStoriesInTestEpic.add(testStory2); + + testStory.setParentIssue(testEpic); + testStory2.setParentIssue(testEpic); + + em.persist(testStory); + em.persist(testStory2); + em.flush(); + } + + @Test + @DisplayName("이슈 상세 조회 테스트 (에픽일때)") + void getIssueTest() { + // when + IssueResponseDto.IssueAndSubIssueDetail result = issueQueryService.getIssue(PROJECT_KEY, testEpic.getId(), + AuthMember.builder().id(member.getId()).build()); + + //result = IssueResponseDto.IssueAndSubIssueDetail(issue=IssueResponseDto.IssueDetail(issueId=1, key=TEST-X, title=테스트 에픽, type=EPIC, status=DO, label=TEST, startDate=2021-01-01, endDate=2021-01-02, content=IssueResponseDto.ContentDto(text=content, imagesURLs=[]), assignee=AssigneeDto(id=1, name=testMember, profileImageURL=www.naver.com)), parentIssue=IssueResponseDto.SubIssueDetail(issueId=null, key=, status=, label=null, type=, title=, assignee=AssigneeDto(id=null, name=, profileImageURL=)), childIssues=[IssueResponseDto.SubIssueDetail(issueId=2, key=TEST-X, status=DO, label=TEST, type=STORY, title=테스트 스토리, assignee=AssigneeDto(id=1, name=testMember, profileImageURL=www.naver.com))]) + // then + assertThat(result.getIssue()) + .extracting("issueId", "key", "title", "type", "status", "label", "startDate", "endDate", "content.text", + "assignee.id", "assignee.name") + .containsExactly( + testEpic.getId(), + testEpic.getNumber(), + testEpic.getTitle(), + IssueType.EPIC.toString(), + testEpic.getStatus().toString(), + String.valueOf(testEpic.getLabel()), + String.valueOf(testEpic.getStartDate()), + String.valueOf(testEpic.getEndDate()), + testEpic.getContent(), + member.getId(), + member.getName() + ); + + assertThat(result.getParentIssue().getIssueId()).isNull(); + assertThat(result.getChildIssues()) + .hasSize(testStoriesInTestEpic.size()) + .extracting("issueId", "key", "title", "type", "status", "label", "assignee.id", "assignee.name") + .contains( + tuple(testStory.getId(), testStory.getNumber(), testStory.getTitle(), IssueType.STORY.toString(), + testStory.getStatus().toString(), String.valueOf(testStory.getLabel()), member.getId(), + member.getName()) + ); + + + } + + // TODO: 이 메서드에서 EpicStatistic 인터페이스를 사용하고 있어, 테스트 복잡성 때문에 통합테스트로 변경함 @Test - @Transactional - void 특정달의_이슈들을_가져오는지_테스트() { + @DisplayName("에픽 통계 조회 테스트") + void getEpicsWithStatsTest() { // given - Project project1 = createProject("프로젝트1", "project12311"); - em.persist(project1); - Epic epic1P1 = createEpic("에픽1", "에픽1 내용", project1); - em.persist(epic1P1); - Epic epic2P1 = createEpic("에픽2", "에픽2 내용", project1); - em.persist(epic2P1); - Story story1P1 = createStory("스토리1", "스토리1 내용", project1, epic2P1); - em.persist(story1P1); - Story story2P1 = createStory("스토리2", "스토리2 내용", project1, epic2P1); - em.persist(story2P1); - - MonthlyReportDto issuesForMonth = issueQueryService.getIssuesForMonth("2024-01", project1.getId()); + Issue testEpic2 = testDataSetup.createIssue(testProject, "테스트 에픽2", IssueType.EPIC, member); + em.persist(testEpic2); + em.flush(); + // when + List result = issueQueryService.getEpicsWithStats( + PROJECT_KEY, AuthMember.builder().id(member.getId()).build()); + +// System.out.println(result); +// EpicStatistic statistic = result.get(0).getStatistic(); +// System.out.println("epicId: " + statistic.getEpicId() + +// ", storiesCount: " + statistic.getStoriesCount() + +// ", statusDo: " + statistic.getStatusDo() + +// ", statusProgress: " + statistic.getStatusProgress() + +// ", statusDone: " + statistic.getStatusDone()); // then - assertThat(issuesForMonth.getContentsByEpic()).hasSize(2); - assertThat(issuesForMonth.getContentsByStory()).hasSize(0); + assertThat(result).hasSize(2); + assertThat(result.get(0).getIssue()) + .extracting("id", "title", "key", "status", "type", "label", "startDate", "endDate", "assignee.id", + "assignee.name") + .containsExactly( + testEpic.getId(), + testEpic.getTitle(), + testEpic.getNumber(), + testEpic.getStatus().toString(), + IssueType.EPIC.toString(), + String.valueOf(testEpic.getLabel()), + String.valueOf(testEpic.getStartDate()), + String.valueOf(testEpic.getEndDate()), + member.getId(), + member.getName() + ); + + assertThat(result.get(0).getStatistic()) + .extracting("epicId", "storiesCount", "statusDo", "statusProgress", "statusDone") + .containsExactly( + testEpic.getId(), + 2L, + 2L, + 0L, + 0L + ); } - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); + @Test + @DisplayName("스토리 조회 테스트 (에픽별)") + void getStoriesByEpicTest() { + // when + List result = issueQueryService.getStoriesByEpic(PROJECT_KEY, + testEpic.getId(), + AuthMember.builder().id(member.getId()).build()); + + // then + assertThat(result) + .hasSize(testStoriesInTestEpic.size()) + .extracting("id", "title", "key", "status", "label", "type", "startDate", "endDate", "parentId", + "assignee.id", "assignee.name") + .contains( + tuple( + testStory.getId(), + testStory.getTitle(), + testStory.getNumber(), + testStory.getStatus().toString(), + String.valueOf(testStory.getLabel()), + IssueType.STORY.toString(), + String.valueOf(testStory.getStartDate()), + String.valueOf(testStory.getEndDate()), + testEpic.getId(), + member.getId(), + member.getName() + ) + ); } - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .status(IssueStatus.DO) - .project(project) - .startDate(LocalDate.of(2024, 1, 5)) - .endDate(LocalDate.of(2024, 3, 2)) - .build(); + @Test + @DisplayName("테스트 조회 테스트 (스토리별)") + void getTasksByStoryTest() { + // given + Issue testTask = testDataSetup.createIssue(testProject, "테스트 태스크", IssueType.TASK, member); + testTask.setParentIssue(testStory); + em.persist(testTask); + em.flush(); + + // when + List result = issueQueryService.getTasksByStory(PROJECT_KEY, + testStory.getId(), + AuthMember.builder().id(member.getId()).build()); + + // then + assertThat(result) + .hasSize(1) + .extracting("id", "title", "key", "status", "label", "type", "startDate", "endDate", "parentId", + "assignee.id", "assignee.name") + .contains( + tuple( + testTask.getId(), + testTask.getTitle(), + testTask.getNumber(), + testTask.getStatus().toString(), + String.valueOf(testTask.getLabel()), + IssueType.TASK.toString(), + String.valueOf(testTask.getStartDate()), + String.valueOf(testTask.getEndDate()), + testStory.getId(), + member.getId(), + member.getName() + ) + ); } - private Story createStory(String title, String content, Project project, Epic epic) { - return Story.builder() - .title(title) - .content(content) - .epic(epic) - .status(IssueStatus.DO) - .startDate(LocalDate.of(2024, 4, 1)) - .endDate(LocalDate.of(2024, 5, 2)) - .project(project) - .build(); + @Test + @DisplayName("에픽 조회 테스트") + void getEpicsTest() { + // given + Issue testEpic2 = testDataSetup.createIssue(testProject, "테스트 에픽2", IssueType.EPIC, member); + em.persist(testEpic2); + em.flush(); + + // when + List result = issueQueryService.getEpics(PROJECT_KEY, + AuthMember.builder().id(member.getId()).build()); + + // then + assertThat(result) + .hasSize(2) + .extracting("id", "title", "key", "status", "type", "label", "assignee.id", "assignee.name") + .contains( + tuple( + testEpic.getId(), + testEpic.getTitle(), + testEpic.getNumber(), + testEpic.getStatus().toString(), + IssueType.EPIC.toString(), + String.valueOf(testEpic.getLabel()), + member.getId(), + member.getName() + ) + ); } + + @Test + @DisplayName("스토리 조회 테스트") + void getStoriesTest() { + // when + List result = issueQueryService.getStories(PROJECT_KEY, + AuthMember.builder().id(member.getId()).build()); + + // then + assertThat(result) + .hasSize(2) + .extracting("id", "title", "key", "status", "type", "label", "assignee.id", "assignee.name") + .contains( + tuple( + testStory.getId(), + testStory.getTitle(), + testStory.getNumber(), + testStory.getStatus().toString(), + IssueType.STORY.toString(), + String.valueOf(testStory.getLabel()), + member.getId(), + member.getName() + ) + ); + } + + @Test + @DisplayName("태스크 조회 테스트") + void getTasksTest() { + // given + Issue testTask = testDataSetup.createIssue(testProject, "테스트 태스크", IssueType.TASK, member); + em.persist(testTask); + em.flush(); + + // when + List result = issueQueryService.getTasks(PROJECT_KEY, + AuthMember.builder().id(member.getId()).build()); + + // then + assertThat(result) + .hasSize(1) + .extracting("id", "title", "key", "status", "type", "label", "assignee.id", "assignee.name") + .contains( + tuple( + testTask.getId(), + testTask.getTitle(), + testTask.getNumber(), + testTask.getStatus().toString(), + IssueType.TASK.toString(), + String.valueOf(testTask.getLabel()), + member.getId(), + member.getName() + ) + ); + } + + } \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java index 600bd64..5dcb1c7 100644 --- a/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java +++ b/src/test/java/dynamicquad/agilehub/sprint/SprintQueryServiceTest.java @@ -1,105 +1,105 @@ -package dynamicquad.agilehub.sprint; - -import static org.assertj.core.api.Assertions.assertThat; - -import dynamicquad.agilehub.issue.domain.Epic; -import dynamicquad.agilehub.issue.domain.Story; -import dynamicquad.agilehub.issue.domain.Task; -import dynamicquad.agilehub.project.domain.Project; -import dynamicquad.agilehub.sprint.domain.Sprint; -import dynamicquad.agilehub.sprint.dto.SprintResponseDto; -import dynamicquad.agilehub.sprint.service.query.SprintQueryService; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import java.util.List; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; - -@ActiveProfiles("test") -@SpringBootTest -class SprintQueryServiceTest { - - @Autowired - private SprintQueryService sprintQueryService; - - @PersistenceContext - private EntityManager em; - - @Test - @Transactional - void 스프린트내에_있는_이슈들_모두_조회() { - // given - prepareData(); - // when - List results = sprintQueryService.getSprints("P1"); - // then - assertThat(results).hasSize(1); - assertThat(results).extracting("title") - .containsExactly("sprint1"); - assertThat(results).extracting("issueCount") - .containsExactly(2L); - - - } - - private void prepareData() { - Project project = createProject("project", "P1"); - em.persist(project); - Epic epic = createEpic("epic1", "content1", project); - Story story = createStory("story1", "content1", project); - Task task = createTask("task1", "content1", project); - em.persist(epic); - em.persist(story); - em.persist(task); - Sprint sprint = Sprint.builder() - .title("sprint1") - .build(); - sprint.setProject(project); - em.persist(sprint); - story.setSprint(sprint); - task.setSprint(sprint); - - Task taskNotExist = createTask("task2", "content2", project); - em.persist(taskNotExist); - } - - - private Project createProject(String projectName, String projectKey) { - return Project.builder() - .name(projectName) - .key(projectKey) - .build(); - } - - - private Epic createEpic(String title, String content, Project project) { - return Epic.builder() - .title(title) - .content(content) - .number("") - .project(project) - .build(); - } - - private Story createStory(String title, String content, Project project) { - return Story.builder() - .title(title) - .content(content) - .project(project) - .number("") - .build(); - } - - private Task createTask(String title, String content, Project project) { - return Task.builder() - .title(title) - .content(content) - .project(project) - .number("") - .build(); - } - -} \ No newline at end of file +//package dynamicquad.agilehub.sprint; +// +//import static org.assertj.core.api.Assertions.assertThat; +// +//import dynamicquad.agilehub.issue.domain.Epic; +//import dynamicquad.agilehub.issue.domain.Story; +//import dynamicquad.agilehub.issue.domain.Task; +//import dynamicquad.agilehub.project.domain.Project; +//import dynamicquad.agilehub.sprint.domain.Sprint; +//import dynamicquad.agilehub.sprint.dto.SprintResponseDto; +//import dynamicquad.agilehub.sprint.service.query.SprintQueryService; +//import jakarta.persistence.EntityManager; +//import jakarta.persistence.PersistenceContext; +//import java.util.List; +//import org.junit.jupiter.api.Test; +//import org.springframework.beans.factory.annotation.Autowired; +//import org.springframework.boot.test.context.SpringBootTest; +//import org.springframework.test.context.ActiveProfiles; +//import org.springframework.transaction.annotation.Transactional; +// +//@ActiveProfiles("test") +//@SpringBootTest +//class SprintQueryServiceTest { +// +// @Autowired +// private SprintQueryService sprintQueryService; +// +// @PersistenceContext +// private EntityManager em; +// +// @Test +// @Transactional +// void 스프린트내에_있는_이슈들_모두_조회() { +// // given +// prepareData(); +// // when +// List results = sprintQueryService.getSprints("P1"); +// // then +// assertThat(results).hasSize(1); +// assertThat(results).extracting("title") +// .containsExactly("sprint1"); +// assertThat(results).extracting("issueCount") +// .containsExactly(2L); +// +// +// } +// +// private void prepareData() { +// Project project = createProject("project", "P1"); +// em.persist(project); +// Epic epic = createEpic("epic1", "content1", project); +// Story story = createStory("story1", "content1", project); +// Task task = createTask("task1", "content1", project); +// em.persist(epic); +// em.persist(story); +// em.persist(task); +// Sprint sprint = Sprint.builder() +// .title("sprint1") +// .build(); +// sprint.setProject(project); +// em.persist(sprint); +// story.setSprint(sprint); +// task.setSprint(sprint); +// +// Task taskNotExist = createTask("task2", "content2", project); +// em.persist(taskNotExist); +// } +// +// +// private Project createProject(String projectName, String projectKey) { +// return Project.builder() +// .name(projectName) +// .key(projectKey) +// .build(); +// } +// +// +// private Epic createEpic(String title, String content, Project project) { +// return Epic.builder() +// .title(title) +// .content(content) +// .number("") +// .project(project) +// .build(); +// } +// +// private Story createStory(String title, String content, Project project) { +// return Story.builder() +// .title(title) +// .content(content) +// .project(project) +// .number("") +// .build(); +// } +// +// private Task createTask(String title, String content, Project project) { +// return Task.builder() +// .title(title) +// .content(content) +// .project(project) +// .number("") +// .build(); +// } +// +//} \ No newline at end of file diff --git a/src/test/java/dynamicquad/agilehub/testSetUp/TestDataSetup.java b/src/test/java/dynamicquad/agilehub/testSetUp/TestDataSetup.java new file mode 100644 index 0000000..e0e73f0 --- /dev/null +++ b/src/test/java/dynamicquad/agilehub/testSetUp/TestDataSetup.java @@ -0,0 +1,85 @@ +package dynamicquad.agilehub.testSetUp; + +import dynamicquad.agilehub.issue.IssueType; +import dynamicquad.agilehub.issue.domain.Issue; +import dynamicquad.agilehub.issue.domain.IssueLabel; +import dynamicquad.agilehub.issue.domain.IssueStatus; +import dynamicquad.agilehub.issue.repository.IssueRepository; +import dynamicquad.agilehub.member.domain.Member; +import dynamicquad.agilehub.member.domain.MemberStatus; +import dynamicquad.agilehub.member.repository.MemberRepository; +import dynamicquad.agilehub.project.domain.MemberProject; +import dynamicquad.agilehub.project.domain.MemberProjectRepository; +import dynamicquad.agilehub.project.domain.MemberProjectRole; +import dynamicquad.agilehub.project.domain.Project; +import dynamicquad.agilehub.project.domain.ProjectRepository; +import java.time.LocalDate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class TestDataSetup { + private final ProjectRepository projectRepository; + private final MemberRepository memberRepository; + private final MemberProjectRepository memberProjectRepository; + private final IssueRepository issueRepository; + + @Autowired + public TestDataSetup(ProjectRepository projectRepository, MemberRepository memberRepository, + MemberProjectRepository memberProjectRepository, IssueRepository issueRepository) { + this.projectRepository = projectRepository; + this.memberRepository = memberRepository; + this.memberProjectRepository = memberProjectRepository; + this.issueRepository = issueRepository; + } + + public Project createAndSaveProject(String projectName, String key) { + Project project = Project.builder() + .name(projectName) + .key(key) + .build(); + projectRepository.save(project); + return project; + } + + public MemberProject createAndSaveMemberProject(Project project, Member member, MemberProjectRole role) { + MemberProject memberProject = MemberProject.builder() + .project(project) + .member(member) + .role(role) + .build(); + + memberProjectRepository.save(memberProject); + return memberProject; + } + + public Member createAndSaveMember(String name, MemberStatus status) { + Member member = Member.builder() + .name(name) + .status(status) + .profileImageUrl("www.naver.com") + .build(); + + memberRepository.save(member); + + return member; + } + + + public Issue createIssue(Project testProject, String title, IssueType issueType, Member assignee) { + Issue issue = Issue.builder() + .project(testProject) + .title(title) + .label(IssueLabel.TEST) + .content("content") + .startDate(LocalDate.of(2021, 1, 1)) + .endDate(LocalDate.of(2021, 1, 2)) + .number("TEST-X") + .issueType(issueType) + .assignee(assignee) + .status(IssueStatus.DO) + .build(); + + return issue; + } +} diff --git a/src/test/resources/sql/cleanup.sql b/src/test/resources/sql/cleanup.sql new file mode 100644 index 0000000..dedb1af --- /dev/null +++ b/src/test/resources/sql/cleanup.sql @@ -0,0 +1,18 @@ +-- 참조하는 테이블 먼저 삭제 +DELETE +FROM member_project; + +DELETE +FROM issue_new; + +DELETE +FROM sprint; +-- 🌟 Sprint 데이터 삭제 추가 + +-- 그 후 참조받는 테이블 삭제 +DELETE +FROM project; + +DELETE +FROM member; + diff --git a/src/test/resources/sql/epic-statistics-test-data.sql b/src/test/resources/sql/epic-statistics-test-data.sql new file mode 100644 index 0000000..c3dd12c --- /dev/null +++ b/src/test/resources/sql/epic-statistics-test-data.sql @@ -0,0 +1,48 @@ +-- 🌟 프로젝트 데이터 삽입 +INSERT INTO project (project_id, name, project_key, created_at, updated_at) +VALUES (1, '테스트 프로젝트', 'TEST', NOW(), NOW()); + +-- 🌟 멤버 데이터 삽입 +INSERT INTO member (member_id, name, profile_image_url, status, created_at, updated_at) +VALUES (1, '홍길동', 'https://example.com/profile.jpg', 'ACTIVE', NOW(), NOW()); + +-- 🌟 멤버-프로젝트 관계 데이터 삽입 +INSERT INTO member_project (member_project_id, member_id, project_id, role) +VALUES (1, 1, 1, 'ADMIN'); + +-- 🌟 스프린트 데이터 삽입 +INSERT INTO sprint (sprint_id, project_id, title, start_date, end_date, target_description, status) +VALUES (1, 1, 'Sprint 1', '2024-03-01', '2024-03-15', '첫 번째 스프린트 목표', 'ACTIVE'); + +-- 에픽 통계 테스트를 위한 추가 데이터 +INSERT INTO issue_new (issue_id, member_id, project_id, sprint_id, issue_type, title, content, + status, label, number, start_date, end_date, story_point, parent_issue_id, created_at, + updated_at) +VALUES +-- 새 에픽 +(100, 1, 1, 1, 'EPIC', '통계 테스트 에픽', '통계 테스트를 위한 에픽입니다', 'DO', 'PLAN', 'TEST-100', '2024-03-01', '2024-03-31', NULL, + NULL, NOW(), NOW()), + +-- DO 상태 스토리들 +(101, 1, 1, 1, 'STORY', 'DO 스토리 1', 'DO 상태 스토리 1', 'DO', 'DESIGN', 'TEST-101', '2024-03-01', '2024-03-10', + 3, 100, NOW(), NOW()), +(102, 1, 1, 1, 'STORY', 'DO 스토리 2', 'DO 상태 스토리 2', 'DO', 'DESIGN', 'TEST-102', '2024-03-01', '2024-03-10', + 2, 100, NOW(), NOW()), + +-- PROGRESS 상태 스토리들 +(103, 1, 1, 1, 'STORY', 'PROGRESS 스토리 1', 'PROGRESS 상태 스토리 1', 'PROGRESS', 'DEVELOP', 'TEST-103', '2024-03-05', '2024-03-15', + 5, 100, NOW(), NOW()), +(104, 1, 1, 1, 'STORY', 'PROGRESS 스토리 2', 'PROGRESS 상태 스토리 2', 'PROGRESS', 'DEVELOP', 'TEST-104', '2024-03-05', '2024-03-15', + 4, 100, NOW(), NOW()), + +-- DONE 상태 스토리 +(105, 1, 1, 1, 'STORY', 'DONE 스토리', 'DONE 상태 스토리', 'DONE', 'DEVELOP', 'TEST-105', '2024-03-10', '2024-03-20', + 3, 100, NOW(), NOW()); + +-- 다른 에픽 (스토리 없음) +INSERT INTO issue_new (issue_id, member_id, project_id, sprint_id, issue_type, title, content, + status, label, number, start_date, end_date, story_point, parent_issue_id, created_at, + updated_at) +VALUES +(200, 1, 1, 1, 'EPIC', '스토리 없는 에픽', '스토리가 없는 에픽입니다', 'DO', 'PLAN', 'TEST-200', '2024-03-01', '2024-03-31', NULL, + NULL, NOW(), NOW()); diff --git a/src/test/resources/sql/test-data.sql b/src/test/resources/sql/test-data.sql new file mode 100644 index 0000000..0964e3a --- /dev/null +++ b/src/test/resources/sql/test-data.sql @@ -0,0 +1,26 @@ +-- 🌟 프로젝트 데이터 삽입 +INSERT INTO project (project_id, name, project_key, created_at, updated_at) +VALUES (1, '테스트 프로젝트', 'TEST', NOW(), NOW()); + +-- 🌟 멤버 데이터 삽입 +INSERT INTO member (member_id, name, profile_image_url, status, created_at, updated_at) +VALUES (1, '홍길동', 'https://example.com/profile.jpg', 'ACTIVE', NOW(), NOW()); + +-- 🌟 멤버-프로젝트 관계 데이터 삽입 +INSERT INTO member_project (member_project_id, member_id, project_id, role) +VALUES (1, 1, 1, 'ADMIN'); + +-- 🌟 스프린트 데이터 삽입 +INSERT INTO sprint (sprint_id, project_id, title, start_date, end_date, target_description, status) +VALUES (1, 1, 'Sprint 1', '2024-03-01', '2024-03-15', '첫 번째 스프린트 목표', 'ACTIVE'); + +-- 🌟 기존 이슈 데이터 중 Sprint ID 설정 추가 +INSERT INTO issue_new (issue_id, member_id, project_id, sprint_id, issue_type, title, content, + status, label, number, start_date, end_date, story_point, parent_issue_id, created_at, + updated_at) +VALUES (1, 1, 1, 1, 'EPIC', '에픽 이슈 1', '에픽 이슈 내용입니다.', 'DO', 'PLAN', 'TEST-1', '2024-03-01', '2024-03-15', NULL, + NULL, NOW(), NOW()), + (2, 1, 1, 1, 'STORY', '스토리 이슈 1', '스토리 이슈 내용입니다.', 'PROGRESS', 'DESIGN', 'TEST-2', '2024-03-01', '2024-03-10', + 5, 1, NOW(), NOW()), + (3, 1, 1, 1, 'TASK', '태스크 이슈 1', '태스크 이슈 내용입니다.', 'DONE', 'DEVELOP', 'TEST-3', '2024-03-03', '2024-03-07', 3, + 2, NOW(), NOW());