Skip to content

Commit 64676a3

Browse files
committed
feat(evaluation): 테스트 케이스 & 기능 연결 완료
1 parent 88224f3 commit 64676a3

File tree

11 files changed

+415
-54
lines changed

11 files changed

+415
-54
lines changed

backend/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ dependencies {
4242
annotationProcessor("org.projectlombok:lombok")
4343
testImplementation("org.springframework.boot:spring-boot-starter-test")
4444
testImplementation("org.springframework.security:spring-security-test")
45+
testImplementation("org.mockito:mockito-junit-jupiter")
4546
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
4647
implementation("org.springframework.boot:spring-boot-starter-webflux")
4748

backend/src/main/java/com/backend/domain/analysis/entity/AnalysisResult.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public class AnalysisResult {
3333
@Column(nullable = false, name = "createData")
3434
private LocalDateTime createDate;
3535

36+
@Setter
3637
@OneToOne(mappedBy = "analysisResult", cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true)
3738
private Score score;
3839
}

backend/src/main/java/com/backend/domain/analysis/service/AnalysisService.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,9 @@ public void analyze(String githubUrl) {
3434
RepositoryData repositoryData = repositoryService.fetchAndSaveRepository(owner, repo);
3535

3636
log.info("🫠 ResponseData: {}", repositoryData);
37-
// TODO: AI 평가
38-
evaluationService.evaluateAndSave(repositoryData); // ★ 이 한 줄로 끝!
37+
// TODO: AI 평가, 저장
38+
evaluationService.evaluateAndSave(repositoryData); //
3939

40-
41-
// TODO: AI 평가 저장
4240
}
4341

4442
private String[] parseGitHubUrl(String githubUrl) {

backend/src/main/java/com/backend/domain/evaluation/service/EvaluationService.java

Lines changed: 21 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33
import com.backend.domain.analysis.entity.AnalysisResult;
44
import com.backend.domain.analysis.entity.Score;
55
import com.backend.domain.analysis.repository.AnalysisResultRepository;
6-
import com.backend.domain.evaluation.dto.EvaluationDto;
6+
import com.backend.domain.analysis.repository.ScoreRepository;
7+
import com.backend.domain.evaluation.dto.AiDto;
78
import com.backend.domain.evaluation.dto.EvaluationDto.AiResult;
89
import com.backend.domain.evaluation.dto.EvaluationDto.Scores;
9-
import com.backend.domain.evaluation.service.AiService;
10-
import com.backend.domain.evaluation.dto.AiDto; // 이미 존재하는 DTO(complete 요청용)
1110
import com.backend.domain.repository.dto.response.RepositoryData;
1211
import com.backend.domain.repository.entity.Repositories;
1312
import com.backend.domain.repository.repository.RepositoryJpaRepository;
@@ -31,27 +30,20 @@
3130
@RequiredArgsConstructor
3231
public class EvaluationService {
3332

34-
private final AiService aiService; // OpenAI 호출 래퍼(이미 프로젝트에 있음)
35-
private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱용
36-
private final RepositoryJpaRepository repositoryJpaRepository; // Repo 엔티티 조회
37-
private final AnalysisResultRepository analysisResultRepository; // 결과 저장
33+
private final AiService aiService;
34+
private final ObjectMapper objectMapper;
35+
private final RepositoryJpaRepository repositoryJpaRepository;
36+
private final AnalysisResultRepository analysisResultRepository;
37+
private final ScoreRepository scoreRepository;
3838

39-
/**
40-
* 평가 + 저장까지 한 번에 처리합니다.
41-
* @param data (RepositoryData) 수집된 깃허브 종합 데이터
42-
* @return 저장된 분석결과 ID(Long)
43-
*/
4439
@Transactional
4540
public Long evaluateAndSave(RepositoryData data) {
46-
// 1) AI 호출
4741
AiResult ai = callAiAndParse(data);
4842

49-
// 2) Repositories 엔티티 찾기 (URL로 조회)
5043
String url = data.getRepositoryUrl();
5144
Repositories repo = repositoryJpaRepository.findByHtmlUrl(url)
5245
.orElseThrow(() -> new BusinessException(ErrorCode.GITHUB_REPO_NOT_FOUND));
5346

54-
// 3) 엔티티 생성 (AnalysisResult + Score)
5547
AnalysisResult analysis = AnalysisResult.builder()
5648
.repositories(repo)
5749
.summary(safe(ai.summary()))
@@ -60,42 +52,35 @@ public Long evaluateAndSave(RepositoryData data) {
6052
.createDate(LocalDateTime.now())
6153
.build();
6254

55+
// AnalysisResult 먼저 저장
56+
AnalysisResult saved = analysisResultRepository.save(analysis);
57+
58+
Scores sc = ai.scores();
6359
Score score = Score.builder()
64-
.analysisResult(analysis) // ★ Score가 연관관계의 주인일 가능성이 높음
65-
.readmeScore(ai.scores().readme())
66-
.testScore(ai.scores().test())
67-
.commitScore(ai.scores().commit())
68-
.cicdScore(ai.scores().cicd())
60+
.analysisResult(saved) // 저장된 analysis에 연결
61+
.readmeScore(sc.readme())
62+
.testScore(sc.test())
63+
.commitScore(sc.commit())
64+
.cicdScore(sc.cicd())
6965
.build();
7066

71-
// AnalysisResult ←→ Score 양쪽 세팅이 필요하다면,
72-
// AnalysisResult에 세터가 없을 수 있어도 JPA가 연관 찾아옵니다(지연 로딩).
73-
// 만약 세터가 있다면 아래 한 줄도 함께 두세요.
74-
// analysis.setScore(score);
67+
analysis.setScore(score);
68+
69+
scoreRepository.save(score);
7570

76-
// 4) 저장 (cascade = ALL 이라면 Score도 함께 저장됩니다)
77-
AnalysisResult saved = analysisResultRepository.save(analysis);
7871
log.info("✅ Evaluation saved. analysisResultId={}", saved.getId());
7972
return saved.getId();
8073
}
8174

82-
/**
83-
* 실제 AI 호출부
84-
* @param data (RepositoryData)
85-
* @return (EvaluationDto.AiResult) AI 결과
86-
*/
8775
public AiResult callAiAndParse(RepositoryData data) {
8876
try {
89-
// content: 원본 데이터(JSON으로 넘기면 모델이 읽기 쉽습니다)
9077
String content = objectMapper.writeValueAsString(data);
91-
92-
// prompt: 모델에게 "이 스키마로만 JSON"을 만들라고 강하게 지시
9378
String prompt = """
9479
You are a senior software engineering reviewer.
9580
Analyze the given GitHub repository data and return ONLY a valid JSON. No commentary.
9681
9782
Scoring: total 100 (README 0~25, TEST 0~25, COMMIT 0~25, CICD 0~25).
98-
Consider test folders, CI configs(e.g., .github/workflows), commit frequency/messages, README depth, etc.
83+
Consider test folders, CI configs (.github/workflows), commit frequency/messages, README depth, etc.
9984
10085
JSON schema:
10186
{
@@ -111,26 +96,18 @@ Consider test folders, CI configs(e.g., .github/workflows), commit frequency/mes
11196

11297
String raw = res.result();
11398
String json = extractJson(raw);
114-
// {"summary":...,"strengths":[...],...} → AiResult로 역직렬화
11599
return objectMapper.readValue(json, new TypeReference<AiResult>() {});
116100
} catch (Exception e) {
117101
log.error("AI evaluation failed", e);
118102
throw new BusinessException(ErrorCode.INTERNAL_ERROR);
119103
}
120104
}
121105

122-
/** 모델이 앞/뒤에 텍스트를 붙여도 JSON 본문만 뽑아냅니다. */
123106
private String extractJson(String text) {
124107
if (text == null) throw new IllegalArgumentException("AI result is null");
125-
// ```json ... ``` 같은 포맷도 대비
126108
String cleaned = text.replaceAll("```json", "```").trim();
127-
128-
// 가장 바깥 { ... } 블록을 정규식으로 추출
129-
Pattern p = Pattern.compile("\\{.*}", Pattern.DOTALL);
130-
Matcher m = p.matcher(cleaned);
109+
Matcher m = Pattern.compile("\\{.*}", Pattern.DOTALL).matcher(cleaned);
131110
if (m.find()) return m.group();
132-
133-
// 못 찾으면 그대로 시도(모델이 운좋게 순수 JSON만 준 경우)
134111
return cleaned;
135112
}
136113

backend/src/main/java/com/backend/global/init/BaseInitData.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@
1414
import org.springframework.boot.CommandLineRunner;
1515
import org.springframework.context.annotation.Bean;
1616
import org.springframework.context.annotation.Configuration;
17+
import org.springframework.context.annotation.Profile;
1718
import org.springframework.transaction.annotation.Transactional;
1819

1920
import java.time.LocalDateTime;
2021
import java.util.List;
2122

23+
@Profile({"local","dev"}) // 필요시 주석처리 하세요 test에는 BaseInitData 안 들어가게 만든 겁니다
2224
@Configuration
2325
@RequiredArgsConstructor
2426
public class BaseInitData {
@@ -41,6 +43,9 @@ CommandLineRunner initPortfolioIQData() {
4143
// 3. 분석 결과 및 점수 데이터 생성
4244
createAnalysisResults();
4345

46+
// [선택3] 위에서 모두 만들고 특정 저장소만 “히스토리 용”으로 하나 더 추가하고 싶다면
47+
// createDemoAnalysisFor("https://github.com/alice/spring-boot-app");
48+
4449
System.out.println("✅ PortfolioIQ 초기 데이터 생성 완료");
4550
};
4651
}
@@ -307,4 +312,27 @@ private int getCicdScore(int index) {
307312
int[] scores = {10, 10, 5, 15, 12, 8, 11};
308313
return scores[index % scores.length];
309314
}
315+
316+
private void createDemoAnalysisFor(String htmlUrl) {
317+
repositoryJpaRepository.findByHtmlUrl(htmlUrl).ifPresent(repo -> {
318+
AnalysisResult ar = AnalysisResult.builder()
319+
.repositories(repo)
320+
.summary("샘플 요약입니다. README, 테스트, 커밋, CI/CD를 종합 평가합니다.")
321+
.strengths("- README가 체계적임\n- 커밋 메시지가 일관적임")
322+
.improvements("- 테스트 커버리지 확장\n- CI 파이프라인 분리")
323+
.createDate(java.time.LocalDateTime.now())
324+
.build();
325+
AnalysisResult saved = analysisResultRepository.save(ar);
326+
327+
Score sc = Score.builder()
328+
.analysisResult(saved)
329+
.readmeScore(20)
330+
.testScore(12)
331+
.commitScore(22)
332+
.cicdScore(18)
333+
.build();
334+
scoreRepository.save(sc);
335+
});
336+
}
337+
310338
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.backend.domain.analysis.service;
2+
3+
import com.backend.domain.evaluation.service.EvaluationService;
4+
import com.backend.domain.repository.dto.response.RepositoryData;
5+
import com.backend.domain.repository.service.RepositoryService;
6+
import com.backend.global.exception.BusinessException;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
import org.mockito.ArgumentCaptor;
10+
import org.springframework.beans.factory.annotation.Autowired;
11+
import org.springframework.boot.test.context.SpringBootTest;
12+
import org.springframework.boot.test.mock.mockito.MockBean;
13+
import org.springframework.test.context.ActiveProfiles;
14+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
15+
16+
import static org.assertj.core.api.Assertions.assertThat;
17+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
18+
import static org.mockito.BDDMockito.any;
19+
import static org.mockito.BDDMockito.given;
20+
import static org.mockito.BDDMockito.then;
21+
import static org.mockito.BDDMockito.times;
22+
23+
@SpringBootTest
24+
@ActiveProfiles("test")
25+
class AnalysisServiceTest {
26+
27+
@Autowired
28+
private AnalysisService analysisService;
29+
30+
@MockitoBean
31+
private RepositoryService repositoryService; // 깃허브 수집 Mock
32+
33+
@MockitoBean
34+
private EvaluationService evaluationService; // AI 평가 Mock
35+
36+
@Test
37+
@DisplayName("analyze → 수집 후 evaluateAndSave 한 번 호출")
38+
void analyze_callsEvaluateOnce() {
39+
// given
40+
String url = "https://github.com/owner/repo";
41+
RepositoryData fake = new RepositoryData();
42+
// RepositoryData가 세터가 없을 수도 있으니, 인자 값 검증은 캡처만 사용
43+
given(repositoryService.fetchAndSaveRepository("owner", "repo")).willReturn(fake);
44+
45+
// when
46+
analysisService.analyze(url);
47+
48+
// then
49+
ArgumentCaptor<RepositoryData> captor = ArgumentCaptor.forClass(RepositoryData.class);
50+
then(evaluationService).should(times(1)).evaluateAndSave(captor.capture());
51+
assertThat(captor.getValue()).isNotNull();
52+
then(repositoryService).should().fetchAndSaveRepository("owner", "repo");
53+
}
54+
55+
@Test
56+
@DisplayName("analyze → 잘못된 URL이면 evaluateAndSave 호출 안 함")
57+
void analyze_invalidUrl_doesNotCallEvaluate() {
58+
assertThatThrownBy(() -> analysisService.analyze("https://notgithub.com/owner/repo"))
59+
.isInstanceOf(BusinessException.class);
60+
61+
then(repositoryService).shouldHaveNoInteractions();
62+
then(evaluationService).shouldHaveNoInteractions();
63+
}
64+
}

backend/src/test/java/com/backend/domain/analysis/test

Whitespace-only changes.
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package com.backend.domain.evaluation.service;
2+
3+
import com.backend.domain.analysis.repository.AnalysisResultRepository;
4+
import com.backend.domain.repository.dto.response.RepositoryData;
5+
import com.backend.domain.repository.entity.Repositories;
6+
import com.backend.domain.repository.repository.RepositoryJpaRepository;
7+
import com.backend.domain.user.entity.User;
8+
import com.backend.domain.user.repository.UserRepository;
9+
import jakarta.mail.MessagingException;
10+
import org.junit.jupiter.api.*;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
import org.springframework.boot.test.context.TestConfiguration;
14+
import org.springframework.context.annotation.Bean;
15+
import org.springframework.context.annotation.Primary;
16+
import org.springframework.core.env.Environment; // ★ 추가
17+
import org.springframework.mail.javamail.JavaMailSender;
18+
import org.springframework.test.context.ActiveProfiles;
19+
import org.springframework.transaction.annotation.Transactional;
20+
21+
import static org.assertj.core.api.Assertions.assertThat;
22+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
23+
import static org.mockito.Mockito.mock;
24+
25+
@SpringBootTest
26+
@ActiveProfiles("test")
27+
@Transactional
28+
class EvaluationServiceExternalIT {
29+
30+
@TestConfiguration
31+
static class MailStubConfig {
32+
@Bean @Primary
33+
JavaMailSender javaMailSender() { return mock(JavaMailSender.class); }
34+
}
35+
36+
@Autowired private EvaluationService evaluationService;
37+
@Autowired private UserRepository userRepository;
38+
@Autowired private RepositoryJpaRepository repositoryJpaRepository;
39+
@Autowired private AnalysisResultRepository analysisResultRepository;
40+
41+
@Autowired private Environment env; // ★ Spring Environment 주입
42+
43+
private String repoUrl;
44+
45+
@BeforeEach
46+
void seed() {
47+
// 1) 우리 프로젝트 방식(.env → Environment)으로 키 확인
48+
String key = env.getProperty("openai.api.key"); // DotenvEnvironmentPostProcessor가 여길 채움
49+
if (key == null || key.isBlank()) {
50+
key = env.getProperty("OPENAI_API_KEY"); // 보조 키 이름도 체크
51+
}
52+
assumeTrue(key != null && !key.isBlank(),
53+
"openai.api.key / OPENAI_API_KEY 가 없어 외부 호출 테스트를 건너뜁니다.");
54+
55+
// 2) 선행 데이터(유저/레포)
56+
User user = userRepository.save(new User("[email protected]", "pw", "ext"));
57+
repoUrl = "https://github.com/test-owner/test-repo";
58+
repositoryJpaRepository.save(
59+
Repositories.builder()
60+
.user(user)
61+
.name("test-repo")
62+
.description("external openai test")
63+
.htmlUrl(repoUrl)
64+
.publicRepository(true)
65+
.mainBranch("main")
66+
.build()
67+
);
68+
}
69+
70+
@Test
71+
@DisplayName("실제 OpenAI 호출되어 토큰 소비되는 외부 연동 테스트")
72+
void evaluateAndSave_realOpenAI_call() throws MessagingException {
73+
RepositoryData data = new RepositoryData();
74+
data.setRepositoryUrl(repoUrl);
75+
76+
Long id = evaluationService.evaluateAndSave(data);
77+
assertThat(id).isNotNull();
78+
assertThat(analysisResultRepository.findById(id)).isPresent();
79+
}
80+
}

0 commit comments

Comments
 (0)