diff --git a/CLAUDE.md b/CLAUDE.md index cc2c128..a2b0545 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ domain/{도메인}/ | AI 오늘 사용량 | `AiToneUsageResult getTodayUsage(Long userId)` (`AiToneUsageQueryService`) | ai → user | | 가입 시 회원 생성 | `Long signUp(UserSignUpCommand)` | user → auth | | 회원 행 잠금 | `void lockUser(Long userId)` (`UserCommandService`, 호출자 트랜잭션 필수) | user → template | +| 회원 존재 확인 | `void validateActive(Long userId)` (`UserQueryService`, 없거나 탈퇴면 `USER404_1`) | user → ai | - **다른 도메인 서비스를 조합하는 조회는 별도 서비스로 분리한다.** 예: 사용량 API는 `UserUsageQueryService` - 순환 참조를 막기 위해서다. 사용량 조합(template·ai 서비스 호출)을 `UserQueryService`에 넣으면, template·ai가 `UserQueryService`를 부르게 될 때 생성자 주입 순환으로 앱이 뜨지 않는다 diff --git a/src/main/java/com/sniptext/domain/ai/annotation/ToneStyleConstraintValidator.java b/src/main/java/com/sniptext/domain/ai/annotation/ToneStyleConstraintValidator.java new file mode 100644 index 0000000..603f8f5 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/annotation/ToneStyleConstraintValidator.java @@ -0,0 +1,18 @@ +package com.sniptext.domain.ai.annotation; + +import java.util.Arrays; + +import com.sniptext.domain.ai.entity.ToneStyle; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** ToneStyle 상수 이름 중 하나와 대소문자까지 같을 때만 통과한다. */ +public class ToneStyleConstraintValidator implements ConstraintValidator { + + @Override + public boolean isValid(String style, ConstraintValidatorContext context) { + return style != null && Arrays.stream(ToneStyle.values()).anyMatch(each -> each.name().equals(style)); + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/annotation/ToneTextConstraintValidator.java b/src/main/java/com/sniptext/domain/ai/annotation/ToneTextConstraintValidator.java new file mode 100644 index 0000000..f9baefb --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/annotation/ToneTextConstraintValidator.java @@ -0,0 +1,32 @@ +package com.sniptext.domain.ai.annotation; + +import com.sniptext.domain.ai.exception.AiErrorCode; +import com.sniptext.global.text.TextWhitespace; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** 비었으면 AI400_2, 길면 AI400_3을 메시지로 남긴다. */ +public class ToneTextConstraintValidator implements ConstraintValidator { + + /** 교정할 원문 최대 길이 (명세 22번, 템플릿 본문 한도와 같다) */ + static final int MAX_LENGTH = 3000; + + @Override + public boolean isValid(String text, ConstraintValidatorContext context) { + if (text == null || TextWhitespace.isBlank(text)) { + return reject(context, AiErrorCode.TEXT_EMPTY); + } + if (text.length() > MAX_LENGTH) { + return reject(context, AiErrorCode.TEXT_TOO_LONG); + } + return true; + } + + private static boolean reject(ConstraintValidatorContext context, AiErrorCode errorCode) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(errorCode.getCode()).addConstraintViolation(); + return false; + } + +} diff --git a/src/main/java/com/sniptext/domain/ai/annotation/ValidToneStyle.java b/src/main/java/com/sniptext/domain/ai/annotation/ValidToneStyle.java new file mode 100644 index 0000000..0c60de1 --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/annotation/ValidToneStyle.java @@ -0,0 +1,28 @@ +package com.sniptext.domain.ai.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * 요청 객체의 문체 검증. null·빈 값·ToneStyle 상수 이름과 정확히 같지 않은 값(소문자 포함)이면 AI400_1. + * 요청 필드를 ToneStyle로 받으면 없는 값이 JSON 변환 실패(COMMON400_1)가 되므로, 문자열로 받아 이것으로 검사한다. + */ +@Documented +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ToneStyleConstraintValidator.class) +public @interface ValidToneStyle { + + String message() default "AI400_1"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/com/sniptext/domain/ai/annotation/ValidToneText.java b/src/main/java/com/sniptext/domain/ai/annotation/ValidToneText.java new file mode 100644 index 0000000..22f4e3c --- /dev/null +++ b/src/main/java/com/sniptext/domain/ai/annotation/ValidToneText.java @@ -0,0 +1,29 @@ +package com.sniptext.domain.ai.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +/** + * 요청 객체의 교정할 원문 검증. null·빈 값·공백만(TextWhitespace 기준, NBSP·BOM 포함)이면 AI400_2, + * String.length()가 3000을 넘으면 AI400_3. 정규화하지 않고 받은 그대로 센다 (NFC 규칙은 템플릿 이름·본문 대상). + * @NotBlank·@Size를 같이 붙이지 않는다 (공백 기준이 화면과 달라진다). + */ +@Documented +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ToneTextConstraintValidator.class) +public @interface ValidToneText { + + String message() default "AI400_2"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/com/sniptext/domain/ai/exception/AiErrorCode.java b/src/main/java/com/sniptext/domain/ai/exception/AiErrorCode.java index 1e56e58..60623a2 100644 --- a/src/main/java/com/sniptext/domain/ai/exception/AiErrorCode.java +++ b/src/main/java/com/sniptext/domain/ai/exception/AiErrorCode.java @@ -11,8 +11,20 @@ @RequiredArgsConstructor public enum AiErrorCode implements ResultCode { + /** style 누락 또는 없는 값 */ + STYLE_INVALID(HttpStatus.BAD_REQUEST, "AI400_1", "문체를 선택해주세요."), + /** text가 비었거나 공백만 입력 */ + TEXT_EMPTY(HttpStatus.BAD_REQUEST, "AI400_2", "교정할 문장을 입력해주세요."), + /** text 3000자 초과 */ + TEXT_TOO_LONG(HttpStatus.BAD_REQUEST, "AI400_3", "문장은 3,000자까지 입력할 수 있어요."), + /** 혐오·차별 표현 또는 욕설·비속어 감지. 횟수 미차감 */ + INAPPROPRIATE_TEXT(HttpStatus.UNPROCESSABLE_CONTENT, "AI422_1", "부적절한 표현이 있어 교정할 수 없어요. 수정 후 다시 시도해 주세요."), /** 하루 한도 소진. 자정(KST)에 리셋 */ - DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요."); + DAILY_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요."), + /** 외부 AI 오류. 횟수 미차감 */ + LANGUAGE_MODEL_FAILED(HttpStatus.BAD_GATEWAY, "AI502_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"), + /** 외부 AI 20초 타임아웃. 횟수 미차감 */ + LANGUAGE_MODEL_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "AI504_1", "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java index 4c8855b..9c9b07f 100644 --- a/src/main/java/com/sniptext/domain/user/service/UserQueryService.java +++ b/src/main/java/com/sniptext/domain/user/service/UserQueryService.java @@ -3,7 +3,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.sniptext.domain.user.exception.UserErrorCode; import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.global.exception.BusinessException; import lombok.RequiredArgsConstructor; @@ -15,4 +17,14 @@ public class UserQueryService { private final UserRepository userRepository; + /** + * 탈퇴하지 않은 회원인지 확인한다. 없거나 탈퇴했으면 USER404_1. + * 회원 행을 외래 키로 쓰는 저장(예: AI 사용량 선점) 전에 불러, 없는 회원이 외래 키 위반(500)으로 끝나지 않게 한다. + */ + public void validateActive(Long userId) { + if (userRepository.findByIdAndDeletedAtIsNull(userId).isEmpty()) { + throw new BusinessException(UserErrorCode.USER_NOT_FOUND); + } + } + } diff --git a/src/main/java/com/sniptext/global/client/llm/AnthropicLanguageModelClient.java b/src/main/java/com/sniptext/global/client/llm/AnthropicLanguageModelClient.java index bed9ba6..402aee6 100644 --- a/src/main/java/com/sniptext/global/client/llm/AnthropicLanguageModelClient.java +++ b/src/main/java/com/sniptext/global/client/llm/AnthropicLanguageModelClient.java @@ -88,8 +88,9 @@ private static LanguageModelResult toResult(MessagesResponse response) { throw new LanguageModelException("언어 모델 응답 없음"); } String stopReason = response.stopReason(); + int outputTokens = outputTokens(response); if (REFUSAL.equals(stopReason)) { - return new LanguageModelResult(joinText(response), true); + return new LanguageModelResult(joinText(response), true, outputTokens); } if (MAX_TOKENS.equals(stopReason)) { // 잘린 결과는 내보내지 않는다 @@ -105,7 +106,14 @@ private static LanguageModelResult toResult(MessagesResponse response) { if (TextWhitespace.isBlank(text)) { throw new LanguageModelException("언어 모델 응답이 빈 텍스트"); } - return new LanguageModelResult(text, false); + return new LanguageModelResult(text, false, outputTokens); + } + + private static int outputTokens(MessagesResponse response) { + if (response.usage() == null || response.usage().outputTokens() == null) { + return 0; + } + return response.usage().outputTokens(); } private static List textBlocks(MessagesResponse response) { @@ -130,7 +138,12 @@ record Message(String role, String content) { } @JsonIgnoreProperties(ignoreUnknown = true) - record MessagesResponse(List content, @JsonProperty("stop_reason") String stopReason) { + record MessagesResponse(List content, @JsonProperty("stop_reason") String stopReason, Usage usage) { + } + + // 잭슨 3은 원시 타입에 값이 없으면 실패할 수 있어, 없을 수 있는 값은 Integer로 받는다 + @JsonIgnoreProperties(ignoreUnknown = true) + record Usage(@JsonProperty("output_tokens") Integer outputTokens) { } @JsonIgnoreProperties(ignoreUnknown = true) diff --git a/src/main/java/com/sniptext/global/client/llm/FakeLanguageModelClient.java b/src/main/java/com/sniptext/global/client/llm/FakeLanguageModelClient.java index 29d0ff9..1eed7ce 100644 --- a/src/main/java/com/sniptext/global/client/llm/FakeLanguageModelClient.java +++ b/src/main/java/com/sniptext/global/client/llm/FakeLanguageModelClient.java @@ -4,7 +4,7 @@ /** * 로컬·테스트용 가짜 언어 모델. 외부를 부르지 않고 사용자 메시지의 원문에 표시만 붙여 돌려준다. - * 사용자 메시지가 <text>...</text>로 감싸져 있으면 태그와 그 안쪽 앞뒤 줄바꿈을 벗긴 원문을 쓴다. + * 사용자 메시지가 <text>...</text>로 감싸져 있으면 태그와 그 안쪽 앞뒤 줄바꿈을 벗긴 원문을 쓴다. 출력 토큰 수는 0이다. * 원문을 로그에 남기지 않는다. */ public class FakeLanguageModelClient implements LanguageModelClient { @@ -17,7 +17,7 @@ public class FakeLanguageModelClient implements LanguageModelClient { @Override public LanguageModelResult complete(LanguageModelRequest request) { - return new LanguageModelResult(PREFIX + unwrap(request.userText()), false); + return new LanguageModelResult(PREFIX + unwrap(request.userText()), false, 0); } private static String unwrap(String userText) { diff --git a/src/main/java/com/sniptext/global/client/llm/LanguageModelResult.java b/src/main/java/com/sniptext/global/client/llm/LanguageModelResult.java index 6f5cb72..b43f38a 100644 --- a/src/main/java/com/sniptext/global/client/llm/LanguageModelResult.java +++ b/src/main/java/com/sniptext/global/client/llm/LanguageModelResult.java @@ -3,8 +3,9 @@ /** * 언어 모델 응답. 결과 문장이 들어 있으므로 로그에 남기지 않는다. * - * @param text 모델이 만든 텍스트 - * @param refused 모델이 답변을 거절했는지 (stop_reason이 refusal) + * @param text 모델이 만든 텍스트 + * @param refused 모델이 답변을 거절했는지 (stop_reason이 refusal) + * @param outputTokens 출력 토큰 수 (생각 토큰 포함). 응답에 없으면 0 */ -public record LanguageModelResult(String text, boolean refused) { +public record LanguageModelResult(String text, boolean refused, int outputTokens) { } diff --git a/src/test/java/com/sniptext/domain/ai/annotation/ValidToneStyleTest.java b/src/test/java/com/sniptext/domain/ai/annotation/ValidToneStyleTest.java new file mode 100644 index 0000000..d9970df --- /dev/null +++ b/src/test/java/com/sniptext/domain/ai/annotation/ValidToneStyleTest.java @@ -0,0 +1,62 @@ +package com.sniptext.domain.ai.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import com.sniptext.domain.ai.entity.ToneStyle; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class ValidToneStyleTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + record Request(@ValidToneStyle String style) { + } + + @BeforeAll + static void setUp() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + validatorFactory.close(); + } + + @ParameterizedTest + @EnumSource(ToneStyle.class) + @DisplayName("문체 상수 이름이면 통과한다") + void valid(ToneStyle style) { + assertThat(messagesOf(style.name())).isEmpty(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "ESSAY", "boss_external", "Casual", " CASUAL", "CASUAL ", "UNKNOWN"}) + @DisplayName("없거나, 비었거나, 없는 값(삭제한 ESSAY 포함)이거나, 대소문자·공백이 다르면 AI400_1") + void invalid(String style) { + assertThat(messagesOf(style)).containsExactly("AI400_1"); + } + + private static List messagesOf(String style) { + return validator.validate(new Request(style)).stream() + .map(ConstraintViolation::getMessage) + .toList(); + } + +} diff --git a/src/test/java/com/sniptext/domain/ai/annotation/ValidToneTextTest.java b/src/test/java/com/sniptext/domain/ai/annotation/ValidToneTextTest.java new file mode 100644 index 0000000..21b4b3a --- /dev/null +++ b/src/test/java/com/sniptext/domain/ai/annotation/ValidToneTextTest.java @@ -0,0 +1,74 @@ +package com.sniptext.domain.ai.annotation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.text.Normalizer; +import java.util.List; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; + +class ValidToneTextTest { + + private static ValidatorFactory validatorFactory; + private static Validator validator; + + record Request(@ValidToneText String text) { + } + + @BeforeAll + static void setUp() { + validatorFactory = Validation.buildDefaultValidatorFactory(); + validator = validatorFactory.getValidator(); + } + + @AfterAll + static void tearDown() { + validatorFactory.close(); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\r\n\t", " ", "", " ", "   \n"}) + @DisplayName("없거나 비었거나 공백만(NBSP·BOM·전각 공백 포함) 있으면 AI400_2") + void empty(String text) { + assertThat(messagesOf(text)).containsExactly("AI400_2"); + } + + @Test + @DisplayName("3000자는 통과하고 3001자는 AI400_3") + void lengthBoundary() { + assertThat(messagesOf("가".repeat(3000))).isEmpty(); + assertThat(messagesOf("가".repeat(3001))).containsExactly("AI400_3"); + } + + @Test + @DisplayName("정규화하지 않고 받은 그대로 센다 (자모 분리형 1500음절은 3000자라 통과, 1501음절은 AI400_3)") + void countsAsReceived() { + assertThat(messagesOf(Normalizer.normalize("가".repeat(1500), Normalizer.Form.NFD))).isEmpty(); + assertThat(messagesOf(Normalizer.normalize("가".repeat(1501), Normalizer.Form.NFD))).containsExactly("AI400_3"); + } + + @Test + @DisplayName("앞뒤 공백이 있어도 내용이 있으면 통과한다") + void valid() { + assertThat(messagesOf(" 이번 달은 외주 못 받는다고 해줘 ")).isEmpty(); + } + + private static List messagesOf(String text) { + return validator.validate(new Request(text)).stream() + .map(ConstraintViolation::getMessage) + .toList(); + } + +} diff --git a/src/test/java/com/sniptext/domain/ai/exception/AiErrorCodeTest.java b/src/test/java/com/sniptext/domain/ai/exception/AiErrorCodeTest.java index 3e4ec37..bbd945e 100644 --- a/src/test/java/com/sniptext/domain/ai/exception/AiErrorCodeTest.java +++ b/src/test/java/com/sniptext/domain/ai/exception/AiErrorCodeTest.java @@ -2,20 +2,42 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.util.stream.Stream; + import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.http.HttpStatus; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class AiErrorCodeTest { - @Test - @DisplayName("하루 한도 소진은 명세의 429 결과 코드와 문구다") - void dailyLimitExceeded() { - AiErrorCode errorCode = AiErrorCode.DAILY_LIMIT_EXCEEDED; + private static final String TEMPORARY_FAILURE = "일시적인 오류로 요청을 처리하지 못했어요. 다시 한 번 물어봐 주시겠어요?"; + + static Stream specCodes() { + return Stream.of( + Arguments.of(AiErrorCode.STYLE_INVALID, 400, "AI400_1", "문체를 선택해주세요."), + Arguments.of(AiErrorCode.TEXT_EMPTY, 400, "AI400_2", "교정할 문장을 입력해주세요."), + Arguments.of(AiErrorCode.TEXT_TOO_LONG, 400, "AI400_3", "문장은 3,000자까지 입력할 수 있어요."), + Arguments.of(AiErrorCode.INAPPROPRIATE_TEXT, 422, "AI422_1", "부적절한 표현이 있어 교정할 수 없어요. 수정 후 다시 시도해 주세요."), + Arguments.of(AiErrorCode.DAILY_LIMIT_EXCEEDED, 429, "AI429_1", "오늘 톤 교정 횟수를 모두 사용했어요."), + Arguments.of(AiErrorCode.LANGUAGE_MODEL_FAILED, 502, "AI502_1", TEMPORARY_FAILURE), + Arguments.of(AiErrorCode.LANGUAGE_MODEL_TIMEOUT, 504, "AI504_1", TEMPORARY_FAILURE)); + } - assertThat(errorCode.getHttpStatus()).isEqualTo(HttpStatus.TOO_MANY_REQUESTS); - assertThat(errorCode.getCode()).isEqualTo("AI429_1"); - assertThat(errorCode.getMessage()).isEqualTo("오늘 톤 교정 횟수를 모두 사용했어요."); + @ParameterizedTest + @MethodSource("specCodes") + @DisplayName("명세 22번의 HTTP 상태·결과 코드·문구와 같다") + void matchesSpec(AiErrorCode errorCode, int status, String code, String message) { + assertThat(errorCode.getHttpStatus().value()).isEqualTo(status); + assertThat(errorCode.getCode()).isEqualTo(code); + assertThat(errorCode.getMessage()).isEqualTo(message); + } + + @Test + @DisplayName("명세에 없는 에러 코드를 두지 않는다") + void noExtraCodes() { + assertThat(AiErrorCode.values()).hasSize((int)specCodes().count()); } } diff --git a/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java b/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java new file mode 100644 index 0000000..aee3c5a --- /dev/null +++ b/src/test/java/com/sniptext/domain/user/service/UserQueryServiceTest.java @@ -0,0 +1,57 @@ +package com.sniptext.domain.user.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import com.sniptext.domain.user.exception.UserErrorCode; +import com.sniptext.domain.user.repository.UserRepository; +import com.sniptext.fixture.UserFixture; +import com.sniptext.global.exception.BusinessException; +import com.sniptext.support.IntegrationTestSupport; + +@Transactional +class UserQueryServiceTest extends IntegrationTestSupport { + + @Autowired + private UserQueryService userQueryService; + + @Autowired + private UserRepository userRepository; + + @Test + @DisplayName("탈퇴하지 않은 회원이면 통과한다") + void activeUser() { + Long userId = userRepository.save(UserFixture.user("active@sniptext.com")).getId(); + + assertThatCode(() -> userQueryService.validateActive(userId)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("없는 회원이면 USER404_1") + void missingUser() { + assertThatThrownBy(() -> userQueryService.validateActive(-1L)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(UserErrorCode.USER_NOT_FOUND); + } + + @Test + @DisplayName("탈퇴 회원이면 USER404_1") + void deletedUser() { + Long userId = userRepository.save( + UserFixture.deletedUser("deleted@sniptext.com", LocalDateTime.of(2026, 10, 1, 0, 0))).getId(); + + assertThatThrownBy(() -> userQueryService.validateActive(userId)) + .isInstanceOf(BusinessException.class) + .extracting("resultCode") + .isEqualTo(UserErrorCode.USER_NOT_FOUND); + } + +} diff --git a/src/test/java/com/sniptext/global/client/llm/AnthropicLanguageModelClientTest.java b/src/test/java/com/sniptext/global/client/llm/AnthropicLanguageModelClientTest.java index 906319d..3aecea0 100644 --- a/src/test/java/com/sniptext/global/client/llm/AnthropicLanguageModelClientTest.java +++ b/src/test/java/com/sniptext/global/client/llm/AnthropicLanguageModelClientTest.java @@ -95,6 +95,33 @@ void endTurnJoinsTextBlocks() { assertThat(result.refused()).isFalse(); } + @Test + @DisplayName("응답의 usage.output_tokens를 출력 토큰 수로 돌려준다 (거절 응답도 같다)") + void outputTokens() { + respond(response("end_turn", 4361, "{\"type\":\"text\",\"text\":\"교정\"}")); + assertThat(client.complete(REQUEST).outputTokens()).isEqualTo(4361); + + server.reset(); + respond(response("refusal", 21)); + assertThat(client.complete(REQUEST).outputTokens()).isEqualTo(21); + } + + @ParameterizedTest + @ValueSource(strings = { + "{\"content\":[{\"type\":\"text\",\"text\":\"교정\"}],\"stop_reason\":\"end_turn\"}", + "{\"content\":[{\"type\":\"text\",\"text\":\"교정\"}],\"stop_reason\":\"end_turn\",\"usage\":{\"input_tokens\":1}}", + "{\"content\":[{\"type\":\"text\",\"text\":\"교정\"}],\"stop_reason\":\"end_turn\",\"usage\":null}" + }) + @DisplayName("usage나 output_tokens가 없으면 출력 토큰 수는 0이고 결과는 정상이다") + void outputTokensMissing(String body) { + respond(body); + + LanguageModelResult result = client.complete(REQUEST); + + assertThat(result.text()).isEqualTo("교정"); + assertThat(result.outputTokens()).isZero(); + } + @Test @DisplayName("stop_sequence도 정상 응답이다") void stopSequence() { @@ -194,8 +221,12 @@ private void respond(String body) { } private static String response(String stopReason, String... blocks) { - return "{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[%s],\"stop_reason\":\"%s\",\"usage\":{\"input_tokens\":1,\"output_tokens\":1}}" - .formatted(String.join(",", blocks), stopReason); + return response(stopReason, 1, blocks); + } + + private static String response(String stopReason, int outputTokens, String... blocks) { + return "{\"id\":\"msg_1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[%s],\"stop_reason\":\"%s\",\"usage\":{\"input_tokens\":1,\"output_tokens\":%d}}" + .formatted(String.join(",", blocks), stopReason, outputTokens); } private static AnthropicLanguageModelClient realClient(String baseUrl, Duration timeout) { diff --git a/src/test/java/com/sniptext/global/client/llm/FakeLanguageModelClientTest.java b/src/test/java/com/sniptext/global/client/llm/FakeLanguageModelClientTest.java index 5485a9b..102e7a7 100644 --- a/src/test/java/com/sniptext/global/client/llm/FakeLanguageModelClientTest.java +++ b/src/test/java/com/sniptext/global/client/llm/FakeLanguageModelClientTest.java @@ -17,6 +17,7 @@ void unwrapsTextTag() { assertThat(result.text()).isEqualTo("[가짜 교정] 안녕하세요\n두 번째 줄") .doesNotContain("", ""); assertThat(result.refused()).isFalse(); + assertThat(result.outputTokens()).isZero(); } @Test