diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java index e075bc4d..c4e48c16 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxService.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.typereport.application; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportGenerationRequestedEventDto; import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReserveResultDto; @@ -114,6 +115,16 @@ public void confirmType( String persona2Title, String persona2Content ) { + validateContentFormat( + typeAnalysis, + typeAnalysisContent, + emotionSummaryContent, + persona1Title, + persona1Content, + persona2Title, + persona2Content + ); + TypeTextContent normalizedTypeAnalysisContent = typeAnalysisContent.normalized(); TypeTextContent normalizedEmotionSummaryContent = emotionSummaryContent.normalized(); TypeEmotionStatsContent normalizedEmotionStats = emotionStats.normalized(); @@ -156,6 +167,29 @@ public void confirmType( crystalLogRepository.markConfirmed(logId); } + private void validateContentFormat( + String typeAnalysis, + TypeTextContent typeAnalysisContent, + TypeTextContent emotionSummaryContent, + String persona1Title, + String persona1Content, + String persona2Title, + String persona2Content + ) { + if (TypeReportContentFormat.containsUnsupportedMarkup(typeAnalysis) + || TypeReportContentFormat.containsUnsupportedMarkup(typeAnalysisContent) + || TypeReportContentFormat.containsUnsupportedMarkup(emotionSummaryContent)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + + if (TypeReportContentFormat.containsUnsupportedMarkup(persona1Title) + || TypeReportContentFormat.containsUnsupportedMarkup(persona1Content) + || TypeReportContentFormat.containsUnsupportedMarkup(persona2Title) + || TypeReportContentFormat.containsUnsupportedMarkup(persona2Content)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } + } + public void failAndRefundType(Long userId, Long reportId, Long logId) { typeReportRepository.markFailed(reportId, TypeReportStatus.FAILED); diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java index 20037a31..7174dc28 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapper.java @@ -1,6 +1,7 @@ package com.devkor.ifive.nadab.domain.typereport.application.mapper; import com.devkor.ifive.nadab.domain.typereport.api.dto.response.TypeReportResponse; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.entity.AnalysisType; import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReport; @@ -19,10 +20,10 @@ public static TypeReportResponse toResponse(TypeReport report, AnalysisType anal report.getTypeAnalysisContent(), report.getEmotionSummaryContent(), report.getEmotionStats(), - report.getPersona1Title(), - report.getPersona1Content(), - report.getPersona2Title(), - report.getPersona2Content(), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona1Title()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona1Content()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona2Title()), + TypeReportContentFormat.sanitizeLegacyMarkup(report.getPersona2Content()), typeImageUrl // 위에서 처리했으므로 그대로 전달 (nullable) ); } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java new file mode 100644 index 00000000..c02ee466 --- /dev/null +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/content/TypeReportContentFormat.java @@ -0,0 +1,36 @@ +package com.devkor.ifive.nadab.domain.typereport.core.content; + +import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; + +import java.util.regex.Pattern; + +public final class TypeReportContentFormat { + + private static final Pattern UNSUPPORTED_MARKS_TAG = + Pattern.compile("<\\s*/?\\s*marks?\\b", Pattern.CASE_INSENSITIVE); + private static final Pattern LEGACY_MARKS_TAG = Pattern.compile( + "<\\s*marks?\\s+text=\"([^\"]*)\"\\s+marks=\\[[^\\]]*]\\s*/?>", + Pattern.CASE_INSENSITIVE + ); + + private TypeReportContentFormat() {} + + public static boolean containsUnsupportedMarkup(String text) { + return text != null && UNSUPPORTED_MARKS_TAG.matcher(text).find(); + } + + public static String sanitizeLegacyMarkup(String text) { + return text == null ? null : LEGACY_MARKS_TAG.matcher(text).replaceAll("$1"); + } + + public static boolean containsUnsupportedMarkup(TypeTextContent content) { + if (content == null || content.styledText() == null || content.styledText().segments() == null) { + return false; + } + + return content.styledText().segments().stream() + .filter(segment -> segment != null) + .map(Segment::text) + .anyMatch(TypeReportContentFormat::containsUnsupportedMarkup); + } +} diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java index 54089374..0c2ee77f 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationService.java @@ -2,6 +2,7 @@ import com.devkor.ifive.nadab.domain.typereport.application.helper.TypeReportInputAssembler; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeEmotionStatsContent; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.devkor.ifive.nadab.domain.typereport.core.dto.AnalysisTypeCandidateDto; @@ -157,6 +158,12 @@ private void validate(TypeReportContentDto dto, String expectedAnalysisTypeCode) throw new AiResponseParseException(ErrorCode.TYPE_REPORT_JSON_MISSING_FIELDS); } + if (TypeReportContentFormat.containsUnsupportedMarkup(dto.typeAnalysis()) + || TypeReportContentFormat.containsUnsupportedMarkup(dto.typeAnalysisContent()) + || TypeReportContentFormat.containsUnsupportedMarkup(dto.emotionSummaryContent())) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + List personas = dto.personas(); if (personas == null || personas.size() != PERSONA_COUNT) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONA_COUNT_INVALID); @@ -172,6 +179,11 @@ private void validate(TypeReportContentDto dto, String expectedAnalysisTypeCode) if (contentLen < PERSONA_CONTENT_MIN || contentLen > PERSONA_CONTENT_MAX) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONA_CONTENT_LENGTH_INVALID); } + + if (TypeReportContentFormat.containsUnsupportedMarkup(p.title()) + || TypeReportContentFormat.containsUnsupportedMarkup(p.content())) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } } } diff --git a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java index 8c8009a4..43782e25 100644 --- a/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java +++ b/src/main/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClient.java @@ -13,6 +13,7 @@ import com.devkor.ifive.nadab.global.shared.reportcontent.Mark; import com.devkor.ifive.nadab.global.shared.reportcontent.Segment; import com.devkor.ifive.nadab.global.shared.reportcontent.StyledText; +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeReportContentFormat; import com.devkor.ifive.nadab.domain.typereport.core.content.TypeTextContent; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonParseException; @@ -140,10 +141,14 @@ private LlmGenerationResult enforceLength(JsonNode raw) { boolean badP1 = false, badP2 = false; if (personasArr.size() >= 1) { - badP1 = isOutOfRange(getPersonaContent(personasArr.get(0)).length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT); + String personaContent = getPersonaContent(personasArr.get(0)); + badP1 = isOutOfRange(personaContent.length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT) + || TypeReportContentFormat.containsUnsupportedMarkup(personaContent); } if (personasArr.size() >= 2) { - badP2 = isOutOfRange(getPersonaContent(personasArr.get(1)).length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT); + String personaContent = getPersonaContent(personasArr.get(1)); + badP2 = isOutOfRange(personaContent.length(), MIN_PERSONA_CONTENT, MAX_PERSONA_CONTENT) + || TypeReportContentFormat.containsUnsupportedMarkup(personaContent); } if (!badTA && !badP1 && !badP2) return new LlmGenerationResult<>(root, LlmTokenUsage.empty()); @@ -207,6 +212,9 @@ private LlmGenerationResult enforceLength(JsonNode raw) { : ErrorCode.TYPE_REPORT_REWRITE_PERSONA_2_LENGTH_INVALID ); } + if (TypeReportContentFormat.containsUnsupportedMarkup(c)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_PERSONAS_INVALID); + } } return new LlmGenerationResult<>(root, tokenUsage); @@ -257,6 +265,10 @@ private void validateStyledText(StyledText styledText, boolean isTypeAnalysis) { String text = segment.text(); + if (TypeReportContentFormat.containsUnsupportedMarkup(text)) { + throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); + } + if (!isTypeAnalysis && text.contains("\n") || text.contains("\r")) { throw new AiResponseParseException(ErrorCode.TYPE_REPORT_AI_SEGMENT_INVALID); } diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxServiceTest.java new file mode 100644 index 00000000..7d78159c --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/TypeReportTxServiceTest.java @@ -0,0 +1,76 @@ +package com.devkor.ifive.nadab.domain.typereport.application; + +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.repository.TypeReportRepository; +import com.devkor.ifive.nadab.domain.typereport.core.service.PendingTypeReportService; +import com.devkor.ifive.nadab.domain.wallet.core.repository.CrystalLogRepository; +import com.devkor.ifive.nadab.domain.wallet.core.repository.UserWalletRepository; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +class TypeReportTxServiceTest { + + @Mock + PendingTypeReportService pendingTypeReportService; + + @Mock + TypeReportRepository typeReportRepository; + + @Mock + UserWalletRepository userWalletRepository; + + @Mock + CrystalLogRepository crystalLogRepository; + + @Mock + ApplicationEventPublisher eventPublisher; + + TypeReportTxService service; + + @BeforeEach + void setUp() { + service = new TypeReportTxService( + pendingTypeReportService, + typeReportRepository, + userWalletRepository, + crystalLogRepository, + eventPublisher, + new ObjectMapper() + ); + } + + @Test + void confirmType_rejects_unsupported_marks_tag_before_updating_report() { + String malformedPersonaContent = "본문 "; + + assertThatThrownBy(() -> service.confirmType( + 49L, + 1L, + null, + "TYPE_A", + "유형 설명", + TypeContentFactory.fromPlainText("유형 설명"), + TypeContentFactory.emptyText(), + TypeContentFactory.emptyEmotionStats(), + "내면의 균형", + malformedPersonaContent, + "상황 적응 전략", + "정상 본문" + )).isInstanceOfSatisfying(AiResponseParseException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.TYPE_REPORT_PERSONAS_INVALID)); + + verifyNoInteractions(typeReportRepository, crystalLogRepository); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapperTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapperTest.java new file mode 100644 index 00000000..9f9482b3 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/application/mapper/TypeReportMapperTest.java @@ -0,0 +1,33 @@ +package com.devkor.ifive.nadab.domain.typereport.application.mapper; + +import com.devkor.ifive.nadab.domain.typereport.api.dto.response.TypeReportResponse; +import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReport; +import com.devkor.ifive.nadab.domain.typereport.core.entity.TypeReportStatus; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TypeReportMapperTest { + + @Test + void toResponse_sanitizes_legacy_marks_tags_in_persona_content() { + TypeReport report = mock(TypeReport.class); + when(report.getStatus()).thenReturn(TypeReportStatus.COMPLETED); + when(report.getPersona1Content()).thenReturn( + "운동으로 자기 관리를 이어가며, " + + "." + ); + when(report.getPersona2Content()).thenReturn( + "이는 를 바탕으로 해요." + ); + + TypeReportResponse response = TypeReportMapper.toResponse(report, null, null); + + assertThat(response.personaContent1()) + .isEqualTo("운동으로 자기 관리를 이어가며, 내적 만족감을 높여요."); + assertThat(response.personaContent2()) + .isEqualTo("이는 변화에 대한 긍정적 태도를 바탕으로 해요."); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationServiceTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationServiceTest.java new file mode 100644 index 00000000..64e06a0d --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/core/service/TypeReportContentGenerationServiceTest.java @@ -0,0 +1,122 @@ +package com.devkor.ifive.nadab.domain.typereport.core.service; + +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.domain.typereport.core.dto.AnalysisTypeCandidateDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.EvidenceCardDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.PatternExtractionResultDto; +import com.devkor.ifive.nadab.domain.typereport.core.dto.TypeReportContentDto; +import com.devkor.ifive.nadab.domain.typereport.infra.TypeReportLlmClient; +import com.devkor.ifive.nadab.global.core.response.ErrorCode; +import com.devkor.ifive.nadab.global.exception.ai.AiResponseParseException; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmTokenUsage; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TypeReportContentGenerationServiceTest { + + @Mock + TypeReportLlmClient llmClient; + + ObjectMapper objectMapper; + TypeReportContentGenerationService service; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + service = new TypeReportContentGenerationService(llmClient, objectMapper); + } + + @Test + void generate_rewrites_persona_content_containing_unsupported_marks_tag() { + JsonNode malformed = reportJson(malformedPersonaContent()); + JsonNode repaired = reportJson("수".repeat(160)); + + when(llmClient.generateRaw(anyString(), anyString(), anyString(), anyString())) + .thenReturn(new LlmGenerationResult<>(malformed, LlmTokenUsage.empty())); + when(llmClient.rewriteOnly(malformed)) + .thenReturn(new LlmGenerationResult<>(repaired, LlmTokenUsage.empty())); + + LlmGenerationResult result = service.generate( + selectedType(), + patterns(), + evidenceCards(), + TypeContentFactory.emptyEmotionStats(), + "TYPE_A" + ); + + assertThat(result.content().personas().get(0).content()).isEqualTo("수".repeat(160)); + verify(llmClient).rewriteOnly(malformed); + } + + @Test + void generate_rejects_persona_content_when_rewrite_keeps_unsupported_marks_tag() { + JsonNode malformed = reportJson(malformedPersonaContent()); + + when(llmClient.generateRaw(anyString(), anyString(), anyString(), anyString())) + .thenReturn(new LlmGenerationResult<>(malformed, LlmTokenUsage.empty())); + when(llmClient.rewriteOnly(malformed)) + .thenReturn(new LlmGenerationResult<>(malformed, LlmTokenUsage.empty())); + + assertThatThrownBy(() -> service.generate( + selectedType(), + patterns(), + evidenceCards(), + TypeContentFactory.emptyEmotionStats(), + "TYPE_A" + )).isInstanceOfSatisfying(AiResponseParseException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.TYPE_REPORT_PERSONAS_INVALID)); + } + + private JsonNode reportJson(String firstPersonaContent) { + ObjectNode root = objectMapper.createObjectNode(); + root.put("analysisTypeCode", "TYPE_A"); + root.put("typeAnalysis", "가".repeat(110) + "\n\n" + "나".repeat(110)); + + ArrayNode personas = root.putArray("personas"); + personas.addObject() + .put("title", "내면의 균형") + .put("content", firstPersonaContent); + personas.addObject() + .put("title", "상황 적응 전략") + .put("content", "다".repeat(160)); + return root; + } + + private String malformedPersonaContent() { + return "가".repeat(100) + + "" + + "나".repeat(20); + } + + private AnalysisTypeCandidateDto selectedType() { + return new AnalysisTypeCandidateDto("TYPE_A", "유형", "설명", "#1", "#2", "#3"); + } + + private PatternExtractionResultDto patterns() { + return new PatternExtractionResultDto(List.of( + new PatternExtractionResultDto.PatternDto("패턴", List.of("D1"), "근거") + )); + } + + private List evidenceCards() { + return List.of(new EvidenceCardDto("D1", LocalDate.of(2026, 8, 19), "근거 카드")); + } +} diff --git a/src/test/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClientTest.java b/src/test/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClientTest.java new file mode 100644 index 00000000..77b5c7b2 --- /dev/null +++ b/src/test/java/com/devkor/ifive/nadab/domain/typereport/infra/TypeReportLlmClientTest.java @@ -0,0 +1,97 @@ +package com.devkor.ifive.nadab.domain.typereport.infra; + +import com.devkor.ifive.nadab.domain.typereport.core.content.TypeContentFactory; +import com.devkor.ifive.nadab.global.core.prompt.type.report.TypeReportPromptLoader; +import com.devkor.ifive.nadab.global.infra.llm.LlmGenerationResult; +import com.devkor.ifive.nadab.global.infra.llm.LlmProvider; +import com.devkor.ifive.nadab.global.infra.llm.LlmRouter; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TypeReportLlmClientTest { + + @Mock + TypeReportPromptLoader promptLoader; + + @Mock + LlmRouter llmRouter; + + @Mock + ChatClient chatClient; + + @Mock + ChatClient.ChatClientRequestSpec requestSpec; + + @Mock + ChatClient.CallResponseSpec callResponseSpec; + + @Test + void rewriteOnly_rewrites_persona_content_containing_unsupported_marks_tag() throws Exception { + ObjectMapper objectMapper = new ObjectMapper(); + TypeReportLlmClient client = new TypeReportLlmClient(promptLoader, objectMapper, llmRouter); + ObjectNode raw = reportJson(objectMapper, malformedPersonaContent()); + String rewrittenContent = "수".repeat(160); + + when(llmRouter.route(LlmProvider.GEMINI)).thenReturn(chatClient); + when(chatClient.prompt()).thenReturn(requestSpec); + when(requestSpec.user(anyString())).thenReturn(requestSpec); + when(requestSpec.options(any())).thenReturn(requestSpec); + when(requestSpec.call()).thenReturn(callResponseSpec); + when(callResponseSpec.chatResponse()).thenReturn(chatResponse( + objectMapper.writeValueAsString(Map.of("text", rewrittenContent)) + )); + + LlmGenerationResult result = client.rewriteOnly(raw); + + assertThat(result.content().path("personas").get(0).path("content").asText()) + .isEqualTo(rewrittenContent); + assertThat(result.content().path("personas").get(1).path("content").asText()) + .isEqualTo("다".repeat(160)); + } + + private ObjectNode reportJson(ObjectMapper objectMapper, String firstPersonaContent) { + ObjectNode root = objectMapper.createObjectNode(); + root.put("analysisTypeCode", "TYPE_A"); + String typeAnalysis = "가".repeat(110) + "\n\n" + "나".repeat(110); + root.put("typeAnalysis", typeAnalysis); + root.set("typeAnalysisContent", objectMapper.valueToTree(TypeContentFactory.fromPlainText(typeAnalysis))); + + ArrayNode personas = root.putArray("personas"); + personas.addObject() + .put("title", "내면의 균형") + .put("content", firstPersonaContent); + personas.addObject() + .put("title", "상황 적응 전략") + .put("content", "다".repeat(160)); + return root; + } + + private String malformedPersonaContent() { + return "가".repeat(100) + + "" + + "나".repeat(20); + } + + private ChatResponse chatResponse(String content) { + return new ChatResponse(List.of(new Generation(new AssistantMessage(content)))); + } +}