Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 과제 제출하기 채점 로직 구현 #649

Merged
merged 29 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e4926e5
feat: 미사용 에러코드 제거
uwoobeat Aug 18, 2024
3fac1f8
refactor: 과제 길이를 integer로 수정
uwoobeat Aug 18, 2024
e3486b8
feat: VO Supplier를 응답하도록 변경
uwoobeat Aug 18, 2024
09bbeb2
feat: 제출 실패사유에 알수없음 추가
uwoobeat Aug 18, 2024
5f5a3b6
feat: 과제 채점 로직 구현
uwoobeat Aug 18, 2024
c2c7946
refactor: 항상 LocalDateTime을 반환하도록 변경
uwoobeat Aug 18, 2024
5c7d03a
refactor: 과제 최소길이를 상수로 추출
uwoobeat Aug 18, 2024
da15ced
feat: 함수형 인터페이스인 AssignmentSubmissionFetcher 추가
uwoobeat Aug 18, 2024
ce96d02
refactor: 패키지 위치 변경
uwoobeat Aug 18, 2024
df4f53a
docs: 투두 추가
uwoobeat Aug 18, 2024
c5e37c6
fix: 지연 평가 작동하도록 로직 분리
uwoobeat Aug 18, 2024
9ffeeeb
feat: 클로저가 되지 않도록 수정
uwoobeat Aug 18, 2024
ca13976
feat: 예외 시그니처 추가
uwoobeat Aug 18, 2024
c39181e
feat: 변경된 과제정보 페치 방식 반영
uwoobeat Aug 18, 2024
0fb75ea
docs: 구현 관련 주석 추가
uwoobeat Aug 18, 2024
4ee8f72
feat: 깃허브 요청 과정에서 발생한 예외는 전부 UNKNOWN으로 변환
uwoobeat Aug 18, 2024
63d3604
feat: 과제 길이 상수 integer로 변경
uwoobeat Aug 18, 2024
1e45fc1
feat: 과제 채점 로직 반영
uwoobeat Aug 18, 2024
c49e06a
feat: 과제 제출 컨트롤러 추가
uwoobeat Aug 18, 2024
b3f8896
test: 과제 채점기 테스트 추가
uwoobeat Aug 18, 2024
aff1d23
Merge branch 'develop' into feature/640-assignment-judge
uwoobeat Aug 18, 2024
f2ab6a7
feat: 스터디 상수 추가
uwoobeat Aug 19, 2024
367489f
docs: 투두 추가
uwoobeat Aug 19, 2024
755ca8f
test: GithubClient 모킹
uwoobeat Aug 19, 2024
467b348
feat: 과제 발행 유틸 메서드 추가
uwoobeat Aug 19, 2024
c112f89
test: 과제 제출 통합 테스트 추가
uwoobeat Aug 19, 2024
02c821e
Merge branch 'develop' into feature/640-assignment-judge
uwoobeat Aug 19, 2024
61afa7f
docs: 주요 로그 추가
uwoobeat Aug 19, 2024
0a4da1b
refactor: private 상수로 변경
uwoobeat Aug 20, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,11 @@ public ResponseEntity<List<AssignmentHistoryResponse>> getAllAssignmentHistories
List<AssignmentHistoryResponse> response = studentStudyHistoryService.getAllAssignmentHistories(studyId);
return ResponseEntity.ok(response);
}

@Operation(summary = "과제 제출하기", description = "과제를 제출합니다. 제출된 과제는 채점되어 제출내역에 반영됩니다.")
@PostMapping("/submit")
public ResponseEntity<Void> submitAssignment(@RequestParam(name = "studyDetailId") Long studyDetailId) {
studentStudyHistoryService.submitAssignment(studyDetailId);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import com.gdschongik.gdsc.domain.study.dao.StudyDetailRepository;
import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository;
import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory;
import com.gdschongik.gdsc.domain.study.domain.AssignmentHistoryGrader;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher;
import com.gdschongik.gdsc.domain.study.domain.Study;
import com.gdschongik.gdsc.domain.study.domain.StudyAssignmentHistoryValidator;
import com.gdschongik.gdsc.domain.study.domain.StudyDetail;
Expand All @@ -21,6 +23,7 @@
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.kohsuke.github.GHRepository;
Expand All @@ -39,6 +42,7 @@ public class StudentStudyHistoryService {
private final AssignmentHistoryRepository assignmentHistoryRepository;
private final StudyHistoryValidator studyHistoryValidator;
private final StudyAssignmentHistoryValidator studyAssignmentHistoryValidator;
private final AssignmentHistoryGrader assignmentHistoryGrader;

@Transactional
public void updateRepository(Long studyHistoryId, RepositoryUpdateRequest request) throws IOException {
Expand All @@ -52,6 +56,7 @@ public void updateRepository(Long studyHistoryId, RepositoryUpdateRequest reques
assignmentHistoryRepository.existsSubmittedAssignmentByMemberAndStudy(currentMember, study);
String ownerRepo = getOwnerRepo(request.repositoryLink());
GHRepository repository = githubClient.getRepository(ownerRepo);
// TODO: GHRepository 등을 wrapper로 감싸서 테스트 가능하도록 변경
studyHistoryValidator.validateUpdateRepository(
isAnyAssignmentSubmitted, String.valueOf(repository.getOwner().getId()), currentMember.getOauthId());

Expand Down Expand Up @@ -85,16 +90,27 @@ public void submitAssignment(Long studyDetailId) {
StudyDetail studyDetail = studyDetailRepository
.findById(studyDetailId)
.orElseThrow(() -> new CustomException(STUDY_DETAIL_NOT_FOUND));
boolean isAppliedToStudy = studyHistoryRepository.existsByMenteeAndStudy(currentMember, studyDetail.getStudy());
Optional<StudyHistory> studyHistory =
studyHistoryRepository.findByMenteeAndStudy(currentMember, studyDetail.getStudy());
LocalDateTime now = LocalDateTime.now();

AssignmentHistory assignmentHistory = findOrCreate(currentMember, studyDetail);

studyAssignmentHistoryValidator.validateSubmitAvailable(isAppliedToStudy, now, studyDetail);
studyAssignmentHistoryValidator.validateSubmitAvailable(studyHistory.isPresent(), now, studyDetail);

// TODO: 과제 채점 및 과제이력 업데이트 로직 추가
AssignmentSubmissionFetcher fetcher = githubClient.getLatestAssignmentSubmissionFetcher(
studyHistory.get().getRepositoryLink(), Math.toIntExact(studyDetail.getWeek()));

assignmentHistoryGrader.judge(fetcher, assignmentHistory);

assignmentHistoryRepository.save(assignmentHistory);

log.info(
"[StudyHistoryService] 과제 제출: studyDetailId={}, menteeId={}, submissionStatus={}, submissionFailureType={}",
studyDetailId,
currentMember.getId(),
assignmentHistory.getSubmissionStatus(),
assignmentHistory.getSubmissionFailureType());
}

private AssignmentHistory findOrCreate(Member currentMember, StudyDetail studyDetail) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public class AssignmentHistory extends BaseEntity {

private String commitHash;

private Long contentLength;
private Integer contentLength;

private LocalDateTime committedAt;

Expand Down Expand Up @@ -85,7 +85,7 @@ public boolean isSubmitted() {

// 데이터 변경 로직

public void success(String submissionLink, String commitHash, Long contentLength, LocalDateTime committedAt) {
public void success(String submissionLink, String commitHash, Integer contentLength, LocalDateTime committedAt) {
this.submissionLink = submissionLink;
this.commitHash = commitHash;
this.contentLength = contentLength;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.gdschongik.gdsc.domain.study.domain;

import static com.gdschongik.gdsc.domain.study.domain.SubmissionFailureType.*;
import static com.gdschongik.gdsc.global.exception.ErrorCode.*;

import com.gdschongik.gdsc.global.annotation.DomainService;
import com.gdschongik.gdsc.global.exception.CustomException;
import com.gdschongik.gdsc.global.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@DomainService
public class AssignmentHistoryGrader {

public static final int MINIMUM_ASSIGNMENT_CONTENT_LENGTH = 300;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static final int MINIMUM_ASSIGNMENT_CONTENT_LENGTH = 300;
private static final int MINIMUM_ASSIGNMENT_CONTENT_LENGTH = 300;

클래스 내에서만 사용되는 것 같은데 public이어야 할 필요가 있을까요?


public void judge(AssignmentSubmissionFetcher assignmentSubmissionFetcher, AssignmentHistory assignmentHistory) {
try {
AssignmentSubmission assignmentSubmission = assignmentSubmissionFetcher.fetch();
judgeAssignmentSubmission(assignmentSubmission, assignmentHistory);
} catch (CustomException e) {
SubmissionFailureType failureType = translateException(e);
assignmentHistory.fail(failureType);
}
}

private void judgeAssignmentSubmission(
AssignmentSubmission assignmentSubmission, AssignmentHistory assignmentHistory) {
if (assignmentSubmission.contentLength() < MINIMUM_ASSIGNMENT_CONTENT_LENGTH) {
assignmentHistory.fail(WORD_COUNT_INSUFFICIENT);
return;
}

assignmentHistory.success(
assignmentSubmission.url(),
assignmentSubmission.commitHash(),
assignmentSubmission.contentLength(),
assignmentSubmission.committedAt());
}

private SubmissionFailureType translateException(CustomException e) {
ErrorCode errorCode = e.getErrorCode();

if (errorCode == GITHUB_CONTENT_NOT_FOUND) {
return LOCATION_UNIDENTIFIABLE;
}

log.warn("[AssignmentHistoryGrader] 과제 제출정보 조회 중 알 수 없는 오류 발생: {}", e.getMessage());

return UNKNOWN;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.gdschongik.gdsc.domain.study.domain;

import java.time.LocalDateTime;

public record AssignmentSubmission(String url, String commitHash, Integer contentLength, LocalDateTime committedAt) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.gdschongik.gdsc.domain.study.domain;

import com.gdschongik.gdsc.global.exception.CustomException;

@FunctionalInterface
public interface AssignmentSubmissionFetchExecutor {
AssignmentSubmission execute(String repo, int week) throws CustomException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.gdschongik.gdsc.domain.study.domain;

import com.gdschongik.gdsc.global.exception.CustomException;

public record AssignmentSubmissionFetcher(String repo, int week, AssignmentSubmissionFetchExecutor fetchExecutor) {
public AssignmentSubmission fetch() throws CustomException {
return fetchExecutor.execute(repo, week);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public class StudyDetail extends BaseEntity {
private Study study;

@Comment("현 주차수")
private Long week;
private Long week; // TODO: Integer로 변경

private String attendanceNumber;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ public enum SubmissionFailureType {
NONE("실패 없음"), // 제출상태 성공 시 사용
NOT_SUBMITTED("미제출"), // 기본값
WORD_COUNT_INSUFFICIENT("글자수 부족"),
LOCATION_UNIDENTIFIABLE("위치 확인불가");
LOCATION_UNIDENTIFIABLE("위치 확인불가"),
UNKNOWN("알 수 없음"),
;

private final String value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,6 @@ public enum ErrorCode {
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, "존재하지 않는 과제 파일입니다."),
;
private final HttpStatus status;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
import static com.gdschongik.gdsc.global.common.constant.GithubConstant.*;
import static com.gdschongik.gdsc.global.exception.ErrorCode.*;

import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmission;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetchExecutor;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher;
import com.gdschongik.gdsc.global.exception.CustomException;
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;
Expand All @@ -31,7 +32,17 @@ public GHRepository getRepository(String ownerRepo) {
}
}

public GithubAssignmentSubmissionResponse getLatestAssignmentSubmission(String repo, int week) {
/**
* 직접 요청을 수행하는 대신, fetcher를 통해 요청을 수행합니다.
* 요청 수행 시 발생하는 예외의 경우 과제 채점에 사용되므로, 실제 요청은 채점 로직 내부에서 수행되어야 합니다.
* 따라서 지연 평가가 가능하도록 {@link AssignmentSubmissionFetchExecutor}를 인자로 받습니다.
* 또한, 인자로 전달된 repo와 week가 closure로 캡쳐되지 않도록 fetcher 내부에 컨텍스트로 저장합니다.
*/
public AssignmentSubmissionFetcher getLatestAssignmentSubmissionFetcher(String repo, int week) {
return new AssignmentSubmissionFetcher(repo, week, this::getLatestAssignmentSubmission);
}

private AssignmentSubmission getLatestAssignmentSubmission(String repo, int week) {
GHRepository ghRepository = getRepository(repo);
String assignmentPath = GITHUB_ASSIGNMENT_PATH.formatted(week);

Expand All @@ -47,12 +58,10 @@ public GithubAssignmentSubmissionResponse getLatestAssignmentSubmission(String r
.iterator()
.next();

LocalDateTime committedAt = getCommitDate(ghLatestCommit)
.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
LocalDateTime committedAt = getCommitDate(ghLatestCommit);

return new GithubAssignmentSubmissionResponse(ghLatestCommit.getSHA1(), content.length(), committedAt);
return new AssignmentSubmission(
ghContent.getHtmlUrl(), ghLatestCommit.getSHA1(), content.length(), committedAt);
}

private GHContent getFileContent(GHRepository ghRepository, String filePath) {
Expand All @@ -71,9 +80,13 @@ private String readFileContent(GHContent ghContent) {
}
}

private Date getCommitDate(GHCommit ghLatestCommit) {
private LocalDateTime getCommitDate(GHCommit ghLatestCommit) {
try {
return ghLatestCommit.getCommitDate();
return ghLatestCommit
.getCommitDate()
.toInstant()
.atZone(ZoneId.systemDefault())
.toLocalDateTime();
} catch (IOException e) {
throw new CustomException(GITHUB_COMMIT_DATE_FETCH_FAILED);
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.gdschongik.gdsc.domain.study.application;

import static com.gdschongik.gdsc.global.common.constant.StudyConstant.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

import com.gdschongik.gdsc.domain.member.domain.Member;
import com.gdschongik.gdsc.domain.member.domain.MemberRole;
import com.gdschongik.gdsc.domain.recruitment.domain.vo.Period;
import com.gdschongik.gdsc.domain.study.dao.AssignmentHistoryRepository;
import com.gdschongik.gdsc.domain.study.dao.StudyHistoryRepository;
import com.gdschongik.gdsc.domain.study.domain.AssignmentHistory;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmission;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionFetcher;
import com.gdschongik.gdsc.domain.study.domain.AssignmentSubmissionStatus;
import com.gdschongik.gdsc.domain.study.domain.Study;
import com.gdschongik.gdsc.domain.study.domain.StudyDetail;
import com.gdschongik.gdsc.domain.study.domain.StudyHistory;
import com.gdschongik.gdsc.helper.IntegrationTest;
import java.time.LocalDateTime;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;

class StudentStudyHistoryServiceTest extends IntegrationTest {

@Autowired
private StudentStudyHistoryService studentStudyHistoryService;

@Autowired
private StudyHistoryRepository studyHistoryRepository;

@Autowired
private AssignmentHistoryRepository assignmentHistoryRepository;

private void setCurrentTime(LocalDateTime now) {
try (MockedStatic<LocalDateTime> mock = Mockito.mockStatic(LocalDateTime.class, Mockito.CALLS_REAL_METHODS)) {
mock.when(LocalDateTime::now).thenReturn(now);
}
}

@Nested
class 과제_제출할때 {

@Test
void 성공한다() {
// given
Member mentor = createAssociateMember();
// TODO: LocalDateTime.now() 관련 테스트 정책 논의 필요
LocalDateTime now = LocalDateTime.now(); // 통합 테스트에서는 LocalDateTime.now()를 사용해야 함
Study study = createStudy(
mentor,
Period.createPeriod(now.minusWeeks(1), now.plusWeeks(7)), // 스터디 기간: 1주 전 ~ 7주 후
Period.createPeriod(now.minusWeeks(2), now.minusWeeks(1))); // 수강신청 기간: 2주 전 ~ 1주 전
StudyDetail studyDetail =
createStudyDetail(study, now.minusDays(6), now.plusDays(1)); // 1주차 기간: 6일 전 ~ 1일 후
publishAssignment(studyDetail);

Member student = createRegularMember();
logoutAndReloginAs(student.getId(), MemberRole.REGULAR);

// 수강신청 valiadtion 로직이 LocalDateTime.now() 기준으로 동작하기 때문에 직접 수강신청 생성
StudyHistory studyHistory = StudyHistory.create(student, study);
studyHistory.updateRepositoryLink(REPOSITORY_LINK);
studyHistoryRepository.save(studyHistory);

// 제출정보 조회 fetcher stubbing
AssignmentSubmissionFetcher mockFetcher = mock(AssignmentSubmissionFetcher.class);
when(mockFetcher.fetch())
.thenReturn(new AssignmentSubmission(REPOSITORY_LINK, COMMIT_HASH, 500, COMMITTED_AT));
when(githubClient.getLatestAssignmentSubmissionFetcher(anyString(), anyInt()))
.thenReturn(mockFetcher);

// when
studentStudyHistoryService.submitAssignment(studyDetail.getId());

// then
AssignmentHistory assignmentHistory =
assignmentHistoryRepository.findById(1L).orElseThrow();
assertThat(assignmentHistory.getSubmissionStatus()).isEqualTo(AssignmentSubmissionStatus.SUCCESS);
assertThat(assignmentHistory.getSubmissionLink()).isEqualTo(REPOSITORY_LINK);
assertThat(assignmentHistory.getCommitHash()).isEqualTo(COMMIT_HASH);
assertThat(assignmentHistory.getContentLength()).isEqualTo(500);
}
}
}
Loading