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
44 changes: 44 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Tests

on:
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: '21'
cache: gradle
- name: Install Redis
run: |
sudo apt-get update
sudo apt-get install -y redis-server
- name: Run tests
run: ./gradlew test
- name: Verify Redis integration tests ran
run: |
python3 - <<'PYTHON'
import xml.etree.ElementTree as ET
report = 'build/test-results/test/TEST-com.person.repit.interview.service.InterviewResultRedisTest.xml'
suite = ET.parse(report).getroot()
assert int(suite.attrib['tests']) > 0, 'Redis integration tests did not run'
assert int(suite.attrib['skipped']) == 0, 'Redis integration tests were skipped'
PYTHON
- name: Upload test reports on failure
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-reports
path: |
build/reports/tests/test/
build/test-results/test/
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.*;

@Getter
Expand All @@ -14,6 +15,7 @@ public class ChatAnswerRequest {
private Long questionId;

@NotNull
@PositiveOrZero
private Integer responseTime;

@NotBlank
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ public class ChatInterviewPrepareRequest {
private Company company;

@Valid
private List<Persona> personas;
private List<@NotNull Persona> personas;

@Valid
@NotEmpty
private List<Question> questions;
private List<@NotNull Question> questions;

@Getter
@Setter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.person.repit.common.type.MessageType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Getter;
import lombok.Setter;

Expand All @@ -16,6 +17,7 @@ public class ChatWebSocketAnswerMessageRequest {
private Long questionId;

@NotNull
@PositiveOrZero
private Integer responseTime;

@NotBlank
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ private FollowQuestionAiResponse parseResponse(String body) {
FollowQuestionAiResponse result =
objectMapper.readValue(json, FollowQuestionAiResponse.class);

if (result.getRequired() == null) {
if (result == null || result.getRequired() == null
|| (Boolean.TRUE.equals(result.getRequired())
&& (!StringUtils.hasText(result.getContent())
|| !StringUtils.hasText(result.getExpectedAnswer())))) {
result = FollowQuestionAiResponse.notRequired();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ public class ChatInterviewServiceImpl implements ChatInterviewService {
@Override
public Mono<ChatInterviewResponse> prepareInterview(ChatInterviewPrepareRequest request) {
return Mono.defer(() -> {
validateQuestionIds(request);
validatePersonas(request);
log.debug("sessionId={}, interviewId={}", request.getSessionId(), request.getInterviewId());
String key = createKey(request.getSessionId());
Expand Down Expand Up @@ -402,6 +403,15 @@ private String firstText(String... values) {
return null;
}

private void validateQuestionIds(ChatInterviewPrepareRequest request) {
var ids = new HashSet<Long>();
for (ChatInterviewPrepareRequest.Question question : request.getQuestions()) {
if (!ids.add(question.getId())) {
throw new InvalidRequestException("질문 ID가 중복되었습니다.");
}
}
}

private void validatePersonas(ChatInterviewPrepareRequest request) {
if (request.getPersonas() == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ private Mono<OutboundMessage> processMessage(String sessionId, String payload) {
return Mono.just(error("메시지 형식이 올바르지 않습니다."));
}

if (request == null) {
return Mono.just(error("메시지 형식이 올바르지 않습니다."));
}
if (request.getType() == null) {
return Mono.just(error("메시지 타입이 필요합니다."));
}
Expand All @@ -119,6 +122,9 @@ private Mono<OutboundMessage> processAnswer(String sessionId, String payload) {
if (message.getResponseTime() == null) {
return Mono.just(error("답변 응답 시간이 존재하지 않습니다."));
}
if (message.getResponseTime() < 0) {
return Mono.just(error("답변 응답 시간은 0 이상이어야 합니다."));
}
if (message.getContent() == null || message.getContent().isBlank()) {
return Mono.just(error("답변 내용이 존재하지 않습니다."));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.person.repit.interview.dto.request;

import com.person.repit.common.exception.handler.GlobalExceptionHandler;
import com.person.repit.interview.controller.ChatInterviewController;
import com.person.repit.interview.dto.response.ChatProgressResponse;
import com.person.repit.interview.service.ChatInterviewService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;

class ChatAnswerRequestTest {
private ChatInterviewService service;
private WebTestClient client;

@BeforeEach
void setUp() {
service = mock(ChatInterviewService.class);
when(service.submitAnswer(eq("s"), any())).thenReturn(Mono.just(ChatProgressResponse.end()));
client = WebTestClient.bindToController(new ChatInterviewController(service))
.controllerAdvice(new GlobalExceptionHandler()).build();
}

@ParameterizedTest
@ValueSource(strings = {"-1", "-2147483648", "null"})
void 음수이거나_없는_응답_시간은_서비스_호출_전에_거절한다(String responseTime) {
client.post().uri("/chat/interviews/s/answers").contentType(MediaType.APPLICATION_JSON)
.bodyValue(answer(responseTime)).exchange()
.expectStatus().isBadRequest().expectBody()
.jsonPath("$.code").isEqualTo("VALIDATION_FAILED")
.jsonPath("$.errors.responseTime").exists();

verify(service, never()).submitAnswer(any(), any());
}

@ParameterizedTest
@ValueSource(ints = {0, 1, Integer.MAX_VALUE})
void 음수가_아닌_응답_시간은_허용한다(int responseTime) {
client.post().uri("/chat/interviews/s/answers").contentType(MediaType.APPLICATION_JSON)
.bodyValue(answer(Integer.toString(responseTime))).exchange().expectStatus().isOk();

verify(service).submitAnswer(eq("s"), any());
}

private String answer(String responseTime) {
return """
{"questionId":1,"responseTime":%s,"content":"답변"}
""".formatted(responseTime);
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.person.repit.interview.dto.request;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.person.repit.common.exception.handler.GlobalExceptionHandler;
import com.person.repit.interview.controller.ChatInterviewController;
import com.person.repit.interview.service.ChatInterviewService;
Expand Down Expand Up @@ -60,6 +62,42 @@ void setUp() {
verify(service, never()).prepareInterview(any());
}

@ParameterizedTest
@MethodSource("nullListElements")
void 배열_안의_null은_서비스_호출_전에_400으로_거절한다(
String field, String elements, String errorField
) throws Exception {
ObjectMapper mapper = new ObjectMapper();
ObjectNode body = (ObjectNode) mapper.readTree(request(""));
body.set(field, mapper.readTree(elements));

client.post().uri("/chat/interviews").contentType(MediaType.APPLICATION_JSON)
.bodyValue(body.toString()).exchange().expectStatus().isBadRequest().expectBody()
.jsonPath("$.code").isEqualTo("VALIDATION_FAILED")
.jsonPath("$.errors['" + errorField + "']").exists();
verify(service, never()).prepareInterview(any());
}

@Test
void 면접관_목록이_null인_기존_요청도_허용한다() {
client.post().uri("/chat/interviews").contentType(MediaType.APPLICATION_JSON)
.bodyValue(request(",\"personas\":null")).exchange().expectStatus().isOk();
verify(service).prepareInterview(any());
}

private static Stream<Arguments> nullListElements() {
String question = "{\"id\":1,\"question\":\"질문\",\"personaId\":3}";
String persona = """
{"personaId":3,"role":"HR","type":"FRIENDLY","tone":"GENTLE","level":"NORMAL"}
""";
return Stream.of(
Arguments.of("questions", "[null]", "questions[0]"),
Arguments.of("questions", "[" + question + ",null]", "questions[1]"),
Arguments.of("personas", "[null]", "personas[0]"),
Arguments.of("personas", "[" + persona + ",null]", "personas[1]")
);
}

private static Stream<Arguments> invalidCompanies() {
return Stream.of(
Arguments.of("{}", "displayName"),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.person.repit.interview.service.impl;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.person.repit.common.metrics.RepitMetrics;
import com.person.repit.interview.dto.request.FollowQuestionAiRequest;
import com.person.repit.interview.service.AiRequestConcurrencyLimiter;
import com.person.repit.interview.type.InterviewTone;
import com.person.repit.interview.type.InterviewType;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.client.ClientResponse;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

class AiQuestionClientResponseTest {
@ParameterizedTest
@ValueSource(strings = {
"null", "{}", "{\"required\":true}",
"{\"required\":true,\"content\":null,\"expectedAnswer\":\"답안\"}",
"{\"required\":true,\"content\":\"\",\"expectedAnswer\":\"답안\"}",
"{\"required\":true,\"content\":\" \",\"expectedAnswer\":\"답안\"}",
"{\"required\":true,\"content\":\"질문\"}",
"{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":null}",
"{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":\"\"}",
"{\"required\":true,\"content\":\"질문\",\"expectedAnswer\":\" \\n\\t\"}"
})
void 불완전한_AI_응답은_꼬리질문을_생성하지_않는다(String text) throws Exception {
StepVerifier.create(client(text).decideFollowQuestion(request()))
.assertNext(response -> {
assertThat(response.getRequired()).isFalse();
assertThat(response.getContent()).isNull();
assertThat(response.getExpectedAnswer()).isNull();
})
.verifyComplete();
}

@ParameterizedTest
@ValueSource(booleans = {true, false})
void 정상_AI_응답은_판단과_내용을_유지한다(boolean required) throws Exception {
String text = """
{"score":2,"required":%s,"intention":"선택 근거 확인",
"content":"왜 선택했나요?","spokenText":"왜 선택했나요?","expectedAnswer":"선택 근거"}
""".formatted(required);

StepVerifier.create(client(text).decideFollowQuestion(request()))
.assertNext(response -> {
assertThat(response.getRequired()).isEqualTo(required);
assertThat(response.getScore()).isEqualTo(2);
assertThat(response.getContent()).isEqualTo("왜 선택했나요?");
assertThat(response.getExpectedAnswer()).isEqualTo("선택 근거");
assertThat(response.getIntention()).isEqualTo("선택 근거 확인");
assertThat(response.getSpokenText()).isEqualTo("왜 선택했나요?");
})
.verifyComplete();
}

private AiQuestionClientImpl client(String text) throws Exception {
ObjectMapper mapper = new ObjectMapper();
String body = mapper.writeValueAsString(Map.of("content", List.of(Map.of("type", "text", "text", text))));
WebClient webClient = WebClient.builder().baseUrl("http://localhost")
.exchangeFunction(request -> Mono.just(ClientResponse.create(HttpStatus.OK)
.header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE).body(body).build()))
.build();
SimpleMeterRegistry registry = new SimpleMeterRegistry();
return new AiQuestionClientImpl(mapper, "stub", 800, webClient,
new RepitMetrics(registry), new AiRequestConcurrencyLimiter(1, registry));
}

private FollowQuestionAiRequest request() {
return FollowQuestionAiRequest.builder().sessionId("s").interviewId(1L).userId(2L).personaId(3L)
.personality(InterviewType.FRIENDLY).tone(InterviewTone.GENTLE)
.questionId(4L).questionContent("질문").expectedAnswer("답안").answerContent("답변")
.responseTime(10).build();
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.person.repit.interview.service.impl;

import com.person.repit.common.exception.InvalidRequestException;
import com.person.repit.common.metrics.RepitMetrics;
import com.person.repit.interview.domain.ChatInterviewSession;
import com.person.repit.interview.domain.ChatQuestion;
Expand Down Expand Up @@ -29,10 +30,12 @@
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.data.redis.core.ReactiveRedisTemplate;
import org.springframework.data.redis.core.ReactiveValueOperations;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;

import java.time.Duration;
import java.util.ArrayList;
Expand All @@ -47,6 +50,7 @@
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;

@SuppressWarnings("unchecked")
Expand Down Expand Up @@ -224,6 +228,29 @@ void setUp() {
assertThat(captor.getValue().getCompanyBrief()).isNull();
}

@ParameterizedTest
@ValueSource(booleans = {false, true})
void 중복_질문_ID는_면접관_목록_유무와_관계없이_저장_전에_거절한다(boolean withPersonas) {
var request = prepareRequest();
request.getQuestions().get(1).setId(request.getQuestions().getFirst().getId());
if (withPersonas) {
var persona = new ChatInterviewPrepareRequest.Persona();
persona.setPersonaId(3L);
persona.setRole(InterviewRole.HR);
persona.setType(InterviewType.FRIENDLY);
persona.setTone(InterviewTone.GENTLE);
persona.setLevel(InterviewLevel.NORMAL);
request.setPersonas(List.of(persona));
}

StepVerifier.create(service.prepareInterview(request))
.expectErrorSatisfies(error -> assertThat(error)
.isInstanceOf(InvalidRequestException.class)
.hasMessage("질문 ID가 중복되었습니다."))
.verify();
verifyNoInteractions(redisTemplate, ttsScheduler, aiQuestionClient);
}

private ChatInterviewPrepareRequest prepareRequest() {
ChatInterviewPrepareRequest request = new ChatInterviewPrepareRequest();
request.setSessionId("session-id");
Expand Down
Loading