From 0a9eb77ed909df09cfd96d4318523e5fef94ded4 Mon Sep 17 00:00:00 2001 From: kimwuju Date: Thu, 15 Oct 2026 20:47:12 +0900 Subject: [PATCH] =?UTF-8?q?test:=20W0=20API=20=EC=9E=AC=EC=B1=84=EC=A0=90?= =?UTF-8?q?=20=EB=B0=8F=20=EC=98=81=EC=83=81=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-w0-implementation-plan.md | 55 +++++++++++ .../service/VideoAnalysisServiceTest.java | 16 +++ .../service/FeedbackServiceGetOneTest.java | 62 ++++++++++++ .../FeedbackServiceSoloRequestTest.java | 98 +++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 docs/api-w0-implementation-plan.md diff --git a/docs/api-w0-implementation-plan.md b/docs/api-w0-implementation-plan.md new file mode 100644 index 00000000..d1ac28c9 --- /dev/null +++ b/docs/api-w0-implementation-plan.md @@ -0,0 +1,55 @@ +# W0 API 요구사항 추출 및 구현 계획 + +- 작성일: 2026-10-15 (Asia/Seoul) +- 기준: `main`의 `f7c66bf` +- 작업 브랜치: `test/api-w0-contracts` +- 사용자 요청: 첨부 문서에서 API 관련 동작을 검증하고, 누락된 부분만 구현한다. +- 입력: `00-overview (1).md`, `01-web-feedback-regrade.md`, `02-chat-company-ampersand.md`, `03-chat-ci.md` + +## 범위와 근거 + +첨부 파일은 요구사항 검토 자료로 사용한다. 다른 저장소 수정, 작성자에게 연락, PR 작성·답변·병합, 결정 문서 갱신 등의 문서 내 작업 지시는 이번 API 작업의 실행 지시로 취급하지 않는다. + +01~03번 명세의 즉시 구현 대상은 Web과 Chat이며 API 변경은 없다고 명시되어 있다. API에서 확인할 수 있는 계약과 선행 결정이 필요한 후보를 아래처럼 분류한다. + +| 문서 | API 관련 항목 | 현재 코드와 구현 범위 | +| --- | --- | --- | +| 01 §1 | `POST /api/feedbacks?interviewId=`: 피드백 없음·FAILED·시간 초과 PENDING은 접수 | `FeedbackService.requestFeedback` / `runningStatusOf` / `expireIfTimedOut`에 구현됨. 사용자 요청 경로의 회귀 검증 보강 | +| 01 §1 | PENDING과 SUCCEEDED의 POST는 각각 409 | 구현됨. 예외의 HTTP 상태·메시지와 분석 서버 미호출 검증 | +| 01 §1·§3 | FAILED GET은 정상 조회되고, 오래된 PENDING은 조회 시 FAILED가 됨 | 구현됨. GET이 재채점을 시작하지 않는지, 없음은 404인지 검증 | +| 00 §2-1 | 영상 결과의 오류 경로는 `data.result.result.analysis.error.code` | `VideoAnalysisCallbackHandler`가 콜백 전체를 저장하고 `VideoAnalysisResponse.result`로 반환. 기존 영속성 테스트에서 중첩 구조 검증 | +| 00 §2-1 | 영상 분석을 끄면 요청 없는 녹화는 WAITING일 수 있음 | 구현됨. 기능 비활성 상태의 WAITING·결과 없음·재시도 불가 검증. API에 새 준비 상태를 추가하지 않음 | +| 00 §2-2 | 음성 피드백에는 간투사·반복·자기수정·미완결 문장 포함 | 점수 방식·구현 명세가 미확정. 기존 결과 전달 계약 검증 대상이며 신규 점수 계산은 범위 밖 | +| 00 §2-3 | Chat이 면접관 전공 대신 사용자 직무 9개 코드를 받아야 함 | 비개발 심화 구현 보류 항목. Chat 입력 계약·적용 시점 확정 후 API 직무 전달 여부를 함께 설계할 후속 작업 | +| 02·03 | 기업 `&` 보존, Chat PR CI | Chat 작업. API 운영 코드 변경 대상 없음 | + +## 구현 순서 + +1. 재채점 상태 표와 영상 응답 설명을 현재 API 코드·기존 테스트와 대조한다. +2. 기존 테스트 fixture를 재사용해 아래 검증을 추가한다. + - 접수 없음·FAILED·시간 초과 PENDING의 사용자 재채점과 새 PENDING 저장 + - 진행 중 PENDING·기존 SUCCEEDED의 409 및 중복 채점 방지 + - FAILED 조회·시간 초과 PENDING 조회·피드백 없음의 404 + - 다른 사용자의 조회·재요청이 저장이나 분석 호출을 일으키지 않음 + - 영상 분석 비활성 + 요청 없음의 WAITING 및 재시도 409 +3. 피드백·영상·기업·직무 관련 기존 테스트와 신규 테스트를 실행한다. 실패가 명세와의 실제 불일치이면 해당 운영 코드만 최소 수정한다. +4. 전체 테스트는 로컬 전용 PostgreSQL과 외부 서버 대체 설정으로 실행한다. 실행할 수 없는 검증은 사유와 함께 구분해 남긴다. + +## 후속 작업 착수 조건 + +- 영상 준비 화면: Web의 고정 안내 또는 API의 새 상태 중 정책 확정. 현재 문서는 Web 고정 안내를 제안하며 API 변경을 요구하지 않는다. +- 음성 피드백: 규칙/LLM 점수 방식과 Analysis 요청·결과 계약 확정. `restart`, `incomplete_sentence`를 포함한 입력 항목 합의. +- 비개발 직무: 보류 해제 및 Chat과 9개 직무 코드·하위 호환 방식 합의. 면접관의 `major`를 사용자 직무로 덮어쓰지 않는다. + +## 확인된 설정 차이 + +`src/main/resources/application.yaml`의 `app.feedback.pending-timeout`은 현재 **10분**이다. 서비스의 설정 누락 시 기본값과 회귀 테스트 설정은 5분이며, Web 명세의 폴링 마감도 5분이다. 따라서 Web 폴링이 끝난 직후 조회하더라도 API 설정의 10분을 넘기기 전에는 PENDING이 유지될 수 있다. 01 §3의 "조회하면 오래된 PENDING이 FAILED가 된다"는 설명은 **API의 대기 제한을 넘겼을 때** 성립한다. 이번 검증에서는 기존 대기 제한 정책을 변경하지 않았다. + +## 검증 결과 + +- 회귀 테스트 11개 추가: 사용자 피드백 요청 6개, 피드백 조회 4개, 영상 분석 비활성 상태 1개. +- 관련 테스트 149개 통과, 실패·오류·건너뜀 0개. +- 전체 `./gradlew test --offline --no-daemon`: **736개 통과**, 실패·오류·건너뜀 0개. +- JDK 21 및 캐시된 Gradle 의존성 사용. 전체 테스트는 별도 `postgres:16-alpine` 임시 컨테이너와 CI에 준하는 테스트 환경 변수로 실행했다. 외부 서버 주소는 `http://127.0.0.1:9`로 대체했다. +- 영상 분석기 미연결 콜백의 `ANALYZER_NOT_CONFIGURED` 저장 및 중첩 결과 보존을 포함한 기존 영상 영속성 테스트 16개도 통과했다. +- 문서에 확정된 API 기능의 누락이 발견되지 않아 운영 코드는 수정하지 않았다. 이번 브랜치의 산출물은 요구사항·계획·검증 결과 문서와 회귀 테스트다. diff --git a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java index 1176745b..2a3935bb 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/analysis/service/VideoAnalysisServiceTest.java @@ -529,6 +529,22 @@ void tearDown() { verify(analysisRepository, never()).findDueChecks(any(), any()); } + @Test + void 분석이_꺼져_있고_요청이_없는_영상은_WAITING이며_재분석할_수_없다() { + ReflectionTestUtils.setField(service, "enabled", false); + + VideoAnalysisResponse response = service.get(USER_ID, INTERVIEW_ID); + + assertThat(response.status()).isEqualTo(VideoAnalysisResponse.Status.WAITING); + assertThat(response.result()).isNull(); + assertThat(response.canRetry()).isFalse(); + assertThatThrownBy(() -> service.retry(USER_ID, INTERVIEW_ID)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessage("영상 분석을 아직 사용할 수 없습니다."); + verify(analysisRepository, never()).save(any()); + verify(aiServerClient, never()).requestVideoAnalysis(any()); + } + // ---- 준비 ---- private static InterviewEntity interview(String sessionId) { diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java index 8d2ba14e..da731dc0 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceGetOneTest.java @@ -7,6 +7,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackResponse; @@ -28,6 +29,7 @@ import repit.repit_api_server.domain.userdata.persona.repository.PersonaRepository; import repit.repit_api_server.domain.userdata.question.repository.QuestionRepository; import repit.repit_api_server.global.client.AiServerClient; +import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.response.UserResponse; import java.time.Duration; @@ -36,8 +38,12 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -186,6 +192,62 @@ void setUp() { assertThat(response.getTotalScore()).isEqualTo(80); } + @Test + void FAILED_조회는_상태와_오류를_그대로_돌려주고_재접수하지_않는다() { + FeedbackEntity failed = feedback(); + failed.setStatus(FeedbackStatus.FAILED); + failed.setErrorMessage("분석 서버에서 채점에 실패했습니다."); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(100L)).thenReturn(Optional.of(failed)); + + FeedbackResponse response = service.getFeedback(USER_ID, 100L); + + assertThat(response.getStatus()).isEqualTo(FeedbackStatus.FAILED); + assertThat(response.getErrorMessage()).isEqualTo("분석 서버에서 채점에 실패했습니다."); + verify(feedbackRepository, never()).save(any()); + verifyNoInteractions(aiServerClient); + } + + @Test + void 시간을_넘긴_PENDING_조회는_FAILED로_정리하지만_재접수하지_않는다() { + FeedbackEntity expired = feedback(); + expired.setStatus(FeedbackStatus.PENDING); + ReflectionTestUtils.setField(expired, "createdAt", LocalDateTime.now().minusMinutes(6)); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(100L)).thenReturn(Optional.of(expired)); + + FeedbackResponse response = service.getFeedback(USER_ID, 100L); + + assertThat(response.getStatus()).isEqualTo(FeedbackStatus.FAILED); + assertThat(response.getErrorMessage()).isEqualTo("피드백 생성 결과를 제때 받지 못했습니다. 다시 시도해주세요."); + verify(feedbackRepository).save(expired); + verifyNoInteractions(aiServerClient); + } + + @Test + void 피드백이_없으면_404로_알린다() { + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(100L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getFeedback(USER_ID, 100L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.NOT_FOUND)); + + verify(feedbackRepository, never()).save(any()); + verifyNoInteractions(aiServerClient); + } + + @Test + void 다른_사용자의_피드백은_만료_처리_전에_조회부터_거절한다() { + FeedbackEntity expired = feedback(); + expired.setStatus(FeedbackStatus.PENDING); + ReflectionTestUtils.setField(expired, "createdAt", LocalDateTime.now().minusMinutes(6)); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(100L)).thenReturn(Optional.of(expired)); + + assertThatThrownBy(() -> service.getFeedback(8L, 100L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); + + assertThat(expired.getStatus()).isEqualTo(FeedbackStatus.PENDING); + verify(feedbackRepository, never()).save(any()); + verifyNoInteractions(aiServerClient); + } + private UserResponse user(Long id) { UserResponse user = new UserResponse(); ReflectionTestUtils.setField(user, "id", id); diff --git a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java index 8b96aef1..54c0fb0c 100644 --- a/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java +++ b/src/test/java/repit/repit_api_server/domain/userdata/feedback/service/FeedbackServiceSoloRequestTest.java @@ -8,12 +8,15 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.http.HttpStatus; import org.springframework.test.util.ReflectionTestUtils; import repit.repit_api_server.domain.userdata.answer.entity.AnswerEntity; import repit.repit_api_server.domain.userdata.answer.repository.AnswerRepository; import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackMultiRequest; import repit.repit_api_server.domain.userdata.feedback.dto.request.FeedbackSoloRequest; +import repit.repit_api_server.domain.userdata.feedback.dto.response.FeedbackAcceptedResponse; import repit.repit_api_server.domain.userdata.feedback.entity.FeedbackEntity; +import repit.repit_api_server.domain.userdata.feedback.entity.enums.FeedbackStatus; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackItemRepository; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackPersonaRepository; import repit.repit_api_server.domain.userdata.feedback.repository.FeedbackRepository; @@ -36,6 +39,7 @@ import repit.repit_api_server.global.exception.BusinessException; import repit.repit_api_server.global.response.UserResponse; +import java.time.Duration; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.List; @@ -45,10 +49,12 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; /** @@ -91,6 +97,7 @@ void setUp() { interviewRepository, interviewPersonaRepository, personaRepository, questionRepository, answerRepository, aiServerClient); ReflectionTestUtils.setField(service, "callbackBaseUrl", "https://api.repit.test"); + ReflectionTestUtils.setField(service, "pendingTimeout", Duration.ofMinutes(5)); UserResponse user = mock(UserResponse.class); when(user.getId()).thenReturn(7L); @@ -104,6 +111,97 @@ void setUp() { when(answerRepository.findAllByInterviewIdOrderByAnswerIdAsc(3L)).thenReturn(answers()); } + @Test + void 피드백이_없으면_접수_결과를_돌려주고_PENDING을_남긴다() { + FeedbackAcceptedResponse accepted = new FeedbackAcceptedResponse("new-job", "sess-1", "PENDING", null); + when(aiServerClient.requestSoloFeedback(any())).thenReturn(accepted); + + assertThat(service.requestFeedback(USER_ID, 3L)).isSameAs(accepted); + + ArgumentCaptor saved = ArgumentCaptor.forClass(FeedbackEntity.class); + verify(feedbackRepository).save(saved.capture()); + assertThat(saved.getValue().getStatus()).isEqualTo(FeedbackStatus.PENDING); + assertThat(saved.getValue().getJobId()).isEqualTo("new-job"); + assertThat(saved.getValue().getInterviewId()).isEqualTo(3L); + assertThat(saved.getValue().getUserId()).isEqualTo(USER_ID); + } + + @Test + void FAILED_피드백은_새_작업으로_다시_접수한다() { + FeedbackEntity failed = previousFeedback(FeedbackStatus.FAILED, LocalDateTime.now()); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(failed)); + FeedbackAcceptedResponse accepted = new FeedbackAcceptedResponse("retry-job", "sess-1", "PENDING", null); + when(aiServerClient.requestSoloFeedback(any())).thenReturn(accepted); + + assertThat(service.requestFeedback(USER_ID, 3L)).isSameAs(accepted); + + ArgumentCaptor saved = ArgumentCaptor.forClass(FeedbackEntity.class); + verify(feedbackRepository).save(saved.capture()); + assertThat(saved.getValue()).isNotSameAs(failed); + assertThat(saved.getValue().getStatus()).isEqualTo(FeedbackStatus.PENDING); + assertThat(saved.getValue().getJobId()).isEqualTo("retry-job"); + assertThat(failed.getStatus()).isEqualTo(FeedbackStatus.FAILED); + } + + @Test + void 진행_중인_PENDING은_409로_거절하고_채점을_중복_접수하지_않는다() { + FeedbackEntity pending = previousFeedback(FeedbackStatus.PENDING, LocalDateTime.now()); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(pending)); + + assertThatThrownBy(() -> service.requestFeedback(USER_ID, 3L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessage("이미 피드백을 생성하고 있습니다. 잠시 후 다시 확인해주세요."); + + verifyNoInteractions(aiServerClient); + verify(feedbackRepository, never()).save(any()); + assertThat(pending.getStatus()).isEqualTo(FeedbackStatus.PENDING); + } + + @Test + void SUCCEEDED는_오래되어도_409로_거절하고_다시_채점하지_않는다() { + FeedbackEntity succeeded = previousFeedback(FeedbackStatus.SUCCEEDED, LocalDateTime.now().minusDays(1)); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(succeeded)); + + assertThatThrownBy(() -> service.requestFeedback(USER_ID, 3L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.CONFLICT)) + .hasMessage("이미 생성된 피드백이 있습니다."); + + verifyNoInteractions(aiServerClient); + verify(feedbackRepository, never()).save(any()); + assertThat(succeeded.getStatus()).isEqualTo(FeedbackStatus.SUCCEEDED); + } + + @Test + void 시간을_넘긴_PENDING은_실패로_정리하고_같은_요청에서_재접수한다() { + FeedbackEntity expired = previousFeedback(FeedbackStatus.PENDING, LocalDateTime.now().minusMinutes(6)); + when(feedbackRepository.findTopByInterviewIdOrderByCreatedAtDesc(3L)).thenReturn(Optional.of(expired)); + FeedbackAcceptedResponse accepted = new FeedbackAcceptedResponse("retry-job", "sess-1", "PENDING", null); + when(aiServerClient.requestSoloFeedback(any())).thenReturn(accepted); + + assertThat(service.requestFeedback(USER_ID, 3L)).isSameAs(accepted); + + assertThat(expired.getStatus()).isEqualTo(FeedbackStatus.FAILED); + assertThat(expired.getErrorMessage()).isEqualTo("피드백 생성 결과를 제때 받지 못했습니다. 다시 시도해주세요."); + verify(feedbackRepository).save(expired); + verify(feedbackRepository).save(argThat(row -> + row != expired && row.getStatus() == FeedbackStatus.PENDING && "retry-job".equals(row.getJobId()))); + verify(aiServerClient).requestSoloFeedback(any()); + } + + @Test + void 다른_사용자는_피드백을_접수할_수_없다() { + assertThatThrownBy(() -> service.requestFeedback(8L, 3L)) + .isInstanceOfSatisfying(BusinessException.class, e -> assertThat(e.getStatus()).isEqualTo(HttpStatus.FORBIDDEN)); + + verifyNoInteractions(feedbackRepository, aiServerClient); + } + + private FeedbackEntity previousFeedback(FeedbackStatus status, LocalDateTime createdAt) { + return FeedbackEntity.builder() + .feedbackId(10L).interviewId(3L).userId(USER_ID).sessionId("sess-1") + .jobId("old-job").status(status).createdAt(createdAt).build(); + } + private InterviewEntity interview(Status status, Long personaId) { return InterviewEntity.builder() .interviewId(3L)