Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
55 changes: 55 additions & 0 deletions docs/api-w0-implementation-plan.md
Original file line number Diff line number Diff line change
@@ -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 기능의 누락이 발견되지 않아 운영 코드는 수정하지 않았다. 이번 브랜치의 산출물은 요구사항·계획·검증 결과 문서와 회귀 테스트다.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

/**
Expand Down Expand Up @@ -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);
Expand All @@ -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<FeedbackEntity> 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<FeedbackEntity> 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)
Expand Down
Loading