From e48c72df5f81e758e1308f8fb9e6d8206f3cf46c Mon Sep 17 00:00:00 2001 From: Jaehyun Ahn <91878695+uwoobeat@users.noreply.github.com> Date: Sun, 18 Aug 2024 14:29:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B3=BC=EC=A0=9C=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B0=8F=20=EA=B9=83=ED=97=88=EB=B8=8C=20=ED=81=B4?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#639)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 정적 임포트 및 임포트 추가 * refactor: 각 예외에 대응되는 에러코드를 할당하도록 변경 * feat: 과제 성공 및 실패 처리 로직 추가 * feat: 과제 휴강 상태 제거 * feat: 제출상태에서 PENDING 제거 * feat: 생성자에서 제출정보 파라미터 제거 * feat: 초기 제출상태를 FAILURE로 시작하도록 변경 * feat: 초기 실패사유를 미제출 상태로 지정 * refactor: 제출여부 검증 로직에 실패사유를 사용하도록 변경 * docs: 투두 추가 * refactor: 순서 변경 * feat: 제출성공 시 설정할 실패사유 추가 * feat: 미사용 에러코드 제거 * feat: 실패처리 시 실패사유 검증로직 및 기존 제출정보 비우기 * test: 과제 히스토리 테스트 추가 --- .../study/domain/AssignmentHistory.java | 47 ++++- .../domain/AssignmentSubmissionStatus.java | 5 +- .../study/domain/SubmissionFailureType.java | 3 +- .../gdsc/global/exception/ErrorCode.java | 5 +- .../infra/github/client/GithubClient.java | 70 +++++--- .../study/domain/AssignmentHistoryTest.java | 165 ++++++++++++++++++ .../global/common/constant/StudyConstant.java | 6 + 7 files changed, 263 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java index 7c5d1edcb..0316c6652 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistory.java @@ -1,9 +1,12 @@ package com.gdschongik.gdsc.domain.study.domain; import static com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus.*; +import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.domain.common.model.BaseEntity; import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -14,6 +17,7 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -44,6 +48,8 @@ public class AssignmentHistory extends BaseEntity { private Long contentLength; + private LocalDateTime committedAt; + @Enumerated(EnumType.STRING) private AssignmentSubmissionStatus submissionStatus; @@ -54,27 +60,50 @@ public class AssignmentHistory extends BaseEntity { private AssignmentHistory( StudyDetail studyDetail, Member member, - String submissionLink, - String commitHash, - Long contentLength, - AssignmentSubmissionStatus submissionStatus) { + AssignmentSubmissionStatus submissionStatus, + SubmissionFailureType submissionFailureType) { this.studyDetail = studyDetail; this.member = member; - this.submissionLink = submissionLink; - this.commitHash = commitHash; - this.contentLength = contentLength; this.submissionStatus = submissionStatus; + this.submissionFailureType = submissionFailureType; } public static AssignmentHistory create(StudyDetail studyDetail, Member member) { return AssignmentHistory.builder() .studyDetail(studyDetail) .member(member) - .submissionStatus(AssignmentSubmissionStatus.PENDING) + .submissionStatus(FAILURE) + .submissionFailureType(NOT_SUBMITTED) .build(); } + // 데이터 조회 로직 + public boolean isSubmitted() { - return submissionStatus == SUCCESS || submissionStatus == FAILURE; + return submissionFailureType != NOT_SUBMITTED; + } + + // 데이터 변경 로직 + + public void success(String submissionLink, String commitHash, Long contentLength, LocalDateTime committedAt) { + this.submissionLink = submissionLink; + this.commitHash = commitHash; + this.contentLength = contentLength; + this.committedAt = committedAt; + this.submissionStatus = SUCCESS; + this.submissionFailureType = NONE; + } + + public void fail(SubmissionFailureType submissionFailureType) { + if (submissionFailureType == NOT_SUBMITTED || submissionFailureType == NONE) { + throw new CustomException(ASSIGNMENT_INVALID_FAILURE_TYPE); + } + + this.submissionLink = null; + this.commitHash = null; + this.contentLength = null; + this.committedAt = null; + this.submissionStatus = FAILURE; + this.submissionFailureType = submissionFailureType; } } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java index 3865abb98..28fcf864a 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/AssignmentSubmissionStatus.java @@ -6,10 +6,9 @@ @Getter @RequiredArgsConstructor public enum AssignmentSubmissionStatus { - PENDING("제출 전"), + // TODO: 클라이언트 응답에는 PENDING 상태 필요하므로, 추후 응답용 enum 클래스 생성 구현 FAILURE("제출 실패"), - SUCCESS("제출 성공"), - CANCELLED("과제 휴강"); // TODO: 제거 및 DB에서 삭제 + SUCCESS("제출 성공"); private final String value; } diff --git a/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java index 252b556e9..bd875f25f 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java +++ b/src/main/java/com/gdschongik/gdsc/domain/study/domain/SubmissionFailureType.java @@ -6,7 +6,8 @@ @Getter @RequiredArgsConstructor public enum SubmissionFailureType { - NOT_SUBMITTED("미제출"), + NONE("실패 없음"), // 제출상태 성공 시 사용 + NOT_SUBMITTED("미제출"), // 기본값 WORD_COUNT_INSUFFICIENT("글자수 부족"), LOCATION_UNIDENTIFIABLE("위치 확인불가"); diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java index 13cbe6fa3..7677ecfc9 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -150,7 +150,7 @@ public enum ErrorCode { ORDER_FINAL_PAYMENT_AMOUNT_MISMATCH(HttpStatus.CONFLICT, "주문 최종결제금액은 주문총액에서 할인금액을 뺀 값이어야 합니다."), // Assignment - ASSIGNMENT_CAN_NOT_BE_UPDATED(HttpStatus.CONFLICT, "휴강인 과제는 수정할 수 없습니다."), + ASSIGNMENT_INVALID_FAILURE_TYPE(HttpStatus.CONFLICT, "유효하지 않은 제출 실패사유입니다."), ASSIGNMENT_DEADLINE_INVALID(HttpStatus.CONFLICT, "과제 마감 기한이 현재보다 빠릅니다."), ASSIGNMENT_STUDY_NOT_APPLIED(HttpStatus.CONFLICT, "해당 스터디에 대한 수강신청 기록이 존재하지 않습니다."), ASSIGNMENT_SUBMIT_NOT_STARTED(HttpStatus.CONFLICT, "아직 과제가 시작되지 않았습니다."), @@ -160,6 +160,9 @@ public enum ErrorCode { // Github GITHUB_REPOSITORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 레포지토리입니다."), + GITHUB_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 파일입니다."), + GITHUB_FILE_READ_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "깃허브 파일 읽기에 실패했습니다."), + GITHUB_COMMIT_DATE_FETCH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "깃허브 커밋 날짜 조회에 실패했습니다."), GITHUB_ASSIGNMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 과제 파일입니다."), ; diff --git a/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java b/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java index d5b94a740..34dd73f9c 100644 --- a/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java +++ b/src/main/java/com/gdschongik/gdsc/infra/github/client/GithubClient.java @@ -1,13 +1,15 @@ package com.gdschongik.gdsc.infra.github.client; import static com.gdschongik.gdsc.global.common.constant.GithubConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; import com.gdschongik.gdsc.global.exception.CustomException; -import com.gdschongik.gdsc.global.exception.ErrorCode; import com.gdschongik.gdsc.infra.github.dto.response.GithubAssignmentSubmissionResponse; import java.io.IOException; +import java.io.InputStream; import java.time.LocalDateTime; import java.time.ZoneId; +import java.util.Date; import lombok.RequiredArgsConstructor; import org.kohsuke.github.GHCommit; import org.kohsuke.github.GHContent; @@ -25,35 +27,55 @@ public GHRepository getRepository(String ownerRepo) { try { return github.getRepository(ownerRepo); } catch (IOException e) { - throw new CustomException(ErrorCode.GITHUB_REPOSITORY_NOT_FOUND); + throw new CustomException(GITHUB_REPOSITORY_NOT_FOUND); } } public GithubAssignmentSubmissionResponse getLatestAssignmentSubmission(String repo, int week) { + GHRepository ghRepository = getRepository(repo); + String assignmentPath = GITHUB_ASSIGNMENT_PATH.formatted(week); + + // GHContent#getSize() 의 경우 한글 문자열을 byte 단위로 계산하기 때문에, 직접 content를 읽어서 길이를 계산 + GHContent ghContent = getFileContent(ghRepository, assignmentPath); + String content = readFileContent(ghContent); + + GHCommit ghLatestCommit = ghRepository + .queryCommits() + .path(assignmentPath) + .list() + .withPageSize(1) + .iterator() + .next(); + + LocalDateTime committedAt = getCommitDate(ghLatestCommit) + .toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime(); + + return new GithubAssignmentSubmissionResponse(ghLatestCommit.getSHA1(), content.length(), committedAt); + } + + private GHContent getFileContent(GHRepository ghRepository, String filePath) { + try { + return ghRepository.getFileContent(filePath); + } catch (IOException e) { + throw new CustomException(GITHUB_CONTENT_NOT_FOUND); + } + } + + private String readFileContent(GHContent ghContent) { + try (InputStream inputStream = ghContent.read()) { + return new String(inputStream.readAllBytes()); + } catch (IOException e) { + throw new CustomException(GITHUB_FILE_READ_FAILED); + } + } + + private Date getCommitDate(GHCommit ghLatestCommit) { try { - GHRepository ghRepository = getRepository(repo); - String assignmentPath = GITHUB_ASSIGNMENT_PATH.formatted(week); - - // GHContent#getSize() 의 경우 한글 문자열을 byte 단위로 계산하기 때문에, 직접 content를 읽어서 길이를 계산 - GHContent ghContent = ghRepository.getFileContent(assignmentPath); - String content = new String(ghContent.read().readAllBytes()); - - GHCommit ghLatestCommit = ghRepository - .queryCommits() - .path(assignmentPath) - .list() - .toList() - .get(0); - - LocalDateTime committedAt = ghLatestCommit - .getCommitDate() - .toInstant() - .atZone(ZoneId.systemDefault()) - .toLocalDateTime(); - - return new GithubAssignmentSubmissionResponse(ghLatestCommit.getSHA1(), content.length(), committedAt); + return ghLatestCommit.getCommitDate(); } catch (IOException e) { - throw new CustomException(ErrorCode.GITHUB_ASSIGNMENT_NOT_FOUND); + throw new CustomException(GITHUB_COMMIT_DATE_FETCH_FAILED); } } } diff --git a/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java new file mode 100644 index 000000000..3c45e14d1 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/study/domain/AssignmentHistoryTest.java @@ -0,0 +1,165 @@ +package com.gdschongik.gdsc.domain.study.domain; + +import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*; +import static com.gdschongik.gdsc.global.exception.ErrorCode.*; +import static org.assertj.core.api.Assertions.*; + +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.helper.FixtureHelper; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AssignmentHistoryTest { + + FixtureHelper fixtureHelper = new FixtureHelper(); + + private Member createMember(Long id) { + return fixtureHelper.createAssociateMember(id); + } + + private Study createStudyWithMentor(Long mentorId) { + Period period = Period.createPeriod(STUDY_START_DATETIME, STUDY_END_DATETIME); + Period applicationPeriod = + Period.createPeriod(STUDY_START_DATETIME.minusDays(7), STUDY_START_DATETIME.minusDays(1)); + return fixtureHelper.createStudyWithMentor(mentorId, period, applicationPeriod); + } + + private StudyDetail createStudyDetailWithAssignment(Study study) { + return fixtureHelper.createStudyDetailWithAssignment( + study, STUDY_DETAIL_START_DATETIME, STUDY_DETAIL_END_DATETIME, STUDY_ASSIGNMENT_DEADLINE_DATETIME); + } + + @Nested + class 빈_과제이력_생성할때 { + + @Test + void 제출상태는_FAILURE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + + // when + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // then + assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + } + + @Test + void 실패사유는_NOT_SUBMITTED이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + + // when + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // then + assertThat(assignmentHistory.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.NOT_SUBMITTED); + } + } + + @Nested + class 과제이력_제출_성공할때 { + + @Test + void 제출상태는_SUCCESS이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // when + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + } + + @Test + void 실패사유는_NONE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + + // when + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // then + assertThat(assignmentHistory.getSubmissionFailureType()).isEqualTo(SubmissionFailureType.NONE); + } + } + + @Nested + class 과제이력_제출_실패할때 { + + @Test + void 제출상태는_FAILURE이다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when + assignmentHistory.fail(SubmissionFailureType.WORD_COUNT_INSUFFICIENT); + + // then + assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.FAILURE); + } + + @Test + void 실패사유는_NOT_SUBMITTED가_될수없다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when, then + assertThatThrownBy(() -> assignmentHistory.fail(SubmissionFailureType.NOT_SUBMITTED)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ASSIGNMENT_INVALID_FAILURE_TYPE.getMessage()); + } + + @Test + void 실패사유는_NONE이_될수없다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when, then + assertThatThrownBy(() -> assignmentHistory.fail(SubmissionFailureType.NONE)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ASSIGNMENT_INVALID_FAILURE_TYPE.getMessage()); + } + + @Test + void 기존_제출정보는_삭제된다() { + // given + Member member = createMember(1L); + Study study = createStudyWithMentor(1L); + StudyDetail studyDetail = createStudyDetailWithAssignment(study); + AssignmentHistory assignmentHistory = AssignmentHistory.create(studyDetail, member); + assignmentHistory.success(SUBMISSION_LINK, COMMIT_HASH, CONTENT_LENGTH, COMMITTED_AT); + + // when + assignmentHistory.fail(SubmissionFailureType.WORD_COUNT_INSUFFICIENT); + + // then + assertThat(assignmentHistory.getSubmissionLink()).isNull(); + assertThat(assignmentHistory.getCommitHash()).isNull(); + assertThat(assignmentHistory.getContentLength()).isNull(); + assertThat(assignmentHistory.getCommittedAt()).isNull(); + } + } +} diff --git a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java index 0e9e26afc..031bf4fbd 100644 --- a/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java +++ b/src/test/java/com/gdschongik/gdsc/global/common/constant/StudyConstant.java @@ -31,4 +31,10 @@ private StudyConstant() {} public static final LocalDateTime STUDY_DETAIL_START_DATETIME = STUDY_START_DATETIME; public static final LocalDateTime STUDY_DETAIL_END_DATETIME = STUDY_DETAIL_START_DATETIME.plusWeeks(1); public static final LocalDateTime STUDY_ASSIGNMENT_DEADLINE_DATETIME = STUDY_DETAIL_END_DATETIME; + + // AssignmentHistory + public static final String SUBMISSION_LINK = "https://github.com/ownername/reponame/blob/main/week1/WIL.md"; + public static final String COMMIT_HASH = "aa11bb22cc33"; + public static final Long CONTENT_LENGTH = 2000L; + public static final LocalDateTime COMMITTED_AT = LocalDateTime.of(2024, 9, 8, 0, 0); }