From f752d991d5ac66e2ce0ed354947fa6083f9a6485 Mon Sep 17 00:00:00 2001 From: kobenlys Date: Thu, 3 Sep 2026 22:47:05 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20sync=20=EC=9E=85=EB=A0=A5=20=EC=A0=9C?= =?UTF-8?q?=EC=95=BD=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=20=EC=8B=A0=ED=98=B8=20=EC=97=86=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 카테고리 7종 규칙을 기존 코드에 적용한 결과다. model 이 컬럼 폭(VARCHAR(80))을 넘어도 검증을 통과해 네이티브 INSERT 까지 내려가 500 이 났고, @Transactional 이라 그 sync 배치 전체가 롤백됐다. 토큰 필드는 상한이 없어 집계 SUM 에서 bigint 오버플로가 가능했다. - TokenRecordRequest: model @Size(80), 토큰 4필드 @Max(1e11) - SyncRequest: records @Size(10000) - SyncSchemaConstraintTest(신규): 제약값과 DDL 을 대조 - SyncControllerTest(신규): @Valid -> 400, 미인증 -> 401 배선 검증 - CardClaudeBadgeThemeTest: 레벨 임계 직전/직후 9케이스 추가 - 세터 왕복, 복합키 중복 등 회귀 신호 없는 테스트 8건 제거 유닛 493, 통합 37 통과. --- .../feature/sync/dto/request/SyncRequest.java | 11 +- .../sync/dto/request/TokenRecordRequest.java | 26 ++- .../entity/UserBadgeGrantIdTest.java | 28 ---- .../entity/DailyTokenUsageIdTest.java | 60 ------- .../entity/RequestAuditLogTest.java | 50 ------ .../card/claude/CardClaudeBadgeThemeTest.java | 31 ++++ .../sync/controller/SyncControllerTest.java | 155 ++++++++++++++++++ .../dto/request/SyncSchemaConstraintTest.java | 121 ++++++++++++++ .../sync/dto/request/SyncValidationTest.java | 100 +++++++++++ 9 files changed, 442 insertions(+), 140 deletions(-) create mode 100644 src/test/java/com/tokenphage/api/feature/sync/controller/SyncControllerTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncSchemaConstraintTest.java diff --git a/src/main/java/com/tokenphage/api/feature/sync/dto/request/SyncRequest.java b/src/main/java/com/tokenphage/api/feature/sync/dto/request/SyncRequest.java index 0ddd5af..211823f 100644 --- a/src/main/java/com/tokenphage/api/feature/sync/dto/request/SyncRequest.java +++ b/src/main/java/com/tokenphage/api/feature/sync/dto/request/SyncRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.util.List; @@ -17,6 +18,14 @@ public record SyncRequest( String deviceId, @NotEmpty(message = "records must not be empty") + @Size(max = MAX_RECORDS, message = "records must not exceed 10000 entries") @Valid List records -) {} +) { + + /** + * 한 요청의 레코드 상한. 1년치 x 모델 10종 약 3,650 의 여유분이다. + * JSON 바디는 톰캣 maxPostSize 대상이 아니라 이 제약이 유일한 방어선이다. + */ + public static final int MAX_RECORDS = 10_000; +} diff --git a/src/main/java/com/tokenphage/api/feature/sync/dto/request/TokenRecordRequest.java b/src/main/java/com/tokenphage/api/feature/sync/dto/request/TokenRecordRequest.java index d98ebd6..5cd9619 100644 --- a/src/main/java/com/tokenphage/api/feature/sync/dto/request/TokenRecordRequest.java +++ b/src/main/java/com/tokenphage/api/feature/sync/dto/request/TokenRecordRequest.java @@ -1,8 +1,10 @@ package com.tokenphage.api.feature.sync.dto.request; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; public record TokenRecordRequest( @@ -11,17 +13,39 @@ public record TokenRecordRequest( String date, @NotBlank(message = "model must not be blank") + @Size(max = MODEL_MAX_LENGTH, message = "model must not exceed 80 characters") String model, @PositiveOrZero(message = "inputTok must be zero or positive") + @Max(value = TOKEN_MAX, message = "inputTok must not exceed 1e11") long inputTok, @PositiveOrZero(message = "outputTok must be zero or positive") + @Max(value = TOKEN_MAX, message = "outputTok must not exceed 1e11") long outputTok, @PositiveOrZero(message = "cacheReadTok must be zero or positive") + @Max(value = TOKEN_MAX, message = "cacheReadTok must not exceed 1e11") long cacheReadTok, @PositiveOrZero(message = "cacheCreateTok must be zero or positive") + @Max(value = TOKEN_MAX, message = "cacheCreateTok must not exceed 1e11") long cacheCreateTok -) {} +) { + + /** + * daily_token_usage.model 컬럼 폭(V1__init.sql). 초과하면 네이티브 INSERT 에서 + * Postgres 22001 이 나고 @Transactional 이라 sync 배치 전체가 롤백된다. + */ + public static final int MODEL_MAX_LENGTH = 80; + + /** + * 토큰 1건 상한. 배지 최고 레벨 임계(1B)의 100배다. + *

+ * 상한을 두는 이유는 집계 SUM 의 bigint 오버플로(Postgres 22003) 차단이다. + * 한 요청이 레코드 상한을 꽉 채우고 모든 필드가 이 값이어도 4e15 로 + * bigint 범위(9.2e18)에 2000배 이상 여백이 남는다. + * 여백 검증은 SyncSchemaConstraintTest 가 담당한다. + */ + public static final long TOKEN_MAX = 100_000_000_000L; +} diff --git a/src/test/java/com/tokenphage/api/domain/badge/repository/entity/UserBadgeGrantIdTest.java b/src/test/java/com/tokenphage/api/domain/badge/repository/entity/UserBadgeGrantIdTest.java index f6a1c20..bafd889 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/repository/entity/UserBadgeGrantIdTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/repository/entity/UserBadgeGrantIdTest.java @@ -77,34 +77,6 @@ class EqualsTest { assertThat(aEqualsB).isTrue(); } - @Test - @DisplayName("equals_githubId만다름_false반환") - void equals_githubId만다름_false반환() { - // given - UserBadgeGrantId a = baseId(); - UserBadgeGrantId b = newId(-999L, BADGE_CODE); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("equals_badgeCode만다름_false반환") - void equals_badgeCode만다름_false반환() { - // given - UserBadgeGrantId a = baseId(); - UserBadgeGrantId b = newId(GITHUB_ID, "beta-tester"); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - @Test @DisplayName("equals_null비교_false반환") void equals_null비교_false반환() { diff --git a/src/test/java/com/tokenphage/api/domain/token/repository/entity/DailyTokenUsageIdTest.java b/src/test/java/com/tokenphage/api/domain/token/repository/entity/DailyTokenUsageIdTest.java index 614ed33..70986a6 100644 --- a/src/test/java/com/tokenphage/api/domain/token/repository/entity/DailyTokenUsageIdTest.java +++ b/src/test/java/com/tokenphage/api/domain/token/repository/entity/DailyTokenUsageIdTest.java @@ -88,66 +88,6 @@ class EqualsTest { assertThat(aEqualsB).isTrue(); } - @Test - @DisplayName("equals_githubId만다름_false반환") - void equals_githubId만다름_false반환() { - // given - DailyTokenUsageId a = baseId(); - DailyTokenUsageId b = newId(-999L, DEVICE_ID, USAGE_DATE, MODEL); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("equals_deviceId만다름_false반환") - void equals_deviceId만다름_false반환() { - // given - DailyTokenUsageId a = baseId(); - DailyTokenUsageId b = newId( - GITHUB_ID, - UUID.fromString("22222222-2222-2222-2222-222222222222"), - USAGE_DATE, - MODEL); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("equals_usageDate만다름_false반환") - void equals_usageDate만다름_false반환() { - // given - DailyTokenUsageId a = baseId(); - DailyTokenUsageId b = newId(GITHUB_ID, DEVICE_ID, LocalDate.of(2026, 6, 1), MODEL); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - - @Test - @DisplayName("equals_model만다름_false반환") - void equals_model만다름_false반환() { - // given - DailyTokenUsageId a = baseId(); - DailyTokenUsageId b = newId(GITHUB_ID, DEVICE_ID, USAGE_DATE, "claude-sonnet-4"); - - // when - boolean result = a.equals(b); - - // then - assertThat(result).isFalse(); - } - @Test @DisplayName("equals_null비교_false반환") void equals_null비교_false반환() { diff --git a/src/test/java/com/tokenphage/api/feature/audit/repository/entity/RequestAuditLogTest.java b/src/test/java/com/tokenphage/api/feature/audit/repository/entity/RequestAuditLogTest.java index 907f540..5d6621c 100644 --- a/src/test/java/com/tokenphage/api/feature/audit/repository/entity/RequestAuditLogTest.java +++ b/src/test/java/com/tokenphage/api/feature/audit/repository/entity/RequestAuditLogTest.java @@ -23,54 +23,4 @@ class RequestAuditLogTest { assertThat(log.getOccurredAt()).isBeforeOrEqualTo(Instant.now()); } - @Test - @DisplayName("감사로그세터_값주입_동일값반환") - void 감사로그세터_값주입_동일값반환() { - // given - RequestAuditLog log = new RequestAuditLog(); - - // when - log.setFeature("auth"); - log.setAction("verify"); - log.setHttpMethod("POST"); - log.setRequestPath("/auth/verify"); - log.setStatusCode(200); - log.setLatencyMs(42); - log.setClientIp("203.0.113.7"); - log.setGithubId(-1001L); - log.setUsername("octocat"); - log.setUserAgent("tokenphage-cli/1.0"); - log.setOutcome("success"); - - // then - assertThat(log.getFeature()).isEqualTo("auth"); - assertThat(log.getAction()).isEqualTo("verify"); - assertThat(log.getHttpMethod()).isEqualTo("POST"); - assertThat(log.getRequestPath()).isEqualTo("/auth/verify"); - assertThat(log.getStatusCode()).isEqualTo(200); - assertThat(log.getLatencyMs()).isEqualTo(42); - assertThat(log.getClientIp()).isEqualTo("203.0.113.7"); - assertThat(log.getGithubId()).isEqualTo(-1001L); - assertThat(log.getUsername()).isEqualTo("octocat"); - assertThat(log.getUserAgent()).isEqualTo("tokenphage-cli/1.0"); - assertThat(log.getOutcome()).isEqualTo("success"); - } - - @Test - @DisplayName("감사로그생성_미인증요청_사용자식별필드는null허용") - void 감사로그생성_미인증요청_사용자식별필드는null허용() { - // given / when - RequestAuditLog log = new RequestAuditLog(); - log.setFeature("badge"); - log.setAction("badge"); - log.setHttpMethod("GET"); - log.setRequestPath("/badge/octocat"); - log.setStatusCode(200); - log.setLatencyMs(5); - - // then - assertThat(log.getGithubId()).isNull(); - assertThat(log.getUsername()).isNull(); - assertThat(log.getClientIp()).isNull(); - } } diff --git a/src/test/java/com/tokenphage/api/feature/badge/svg/theme/card/claude/CardClaudeBadgeThemeTest.java b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/card/claude/CardClaudeBadgeThemeTest.java index 7f4a065..1ba957c 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/svg/theme/card/claude/CardClaudeBadgeThemeTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/card/claude/CardClaudeBadgeThemeTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import java.util.ArrayList; import java.util.List; @@ -128,6 +130,35 @@ void lv5() { } } + @Nested + @DisplayName("레벨 임계 경계값") + class LevelBoundaries { + + @ParameterizedTest(name = "누적 {0} -> Lv{1}") + @DisplayName("레벨계산_임계직전과직후_레벨전환") + @CsvSource({ + "0, 1", + "9999999, 1", + "10000000, 2", + "99999999, 2", + "100000000, 3", + "499999999, 3", + "500000000, 4", + "999999999, 4", + "1000000000, 5" + }) + void 레벨계산_임계직전과직후_레벨전환(long totalTokens, int expected) { + // given: 임계는 LV2 10M / LV3 100M / LV4 500M / LV5 1B 이고 비교는 < 다 + + // when + int level = CardClaudeMascot.levelFor(totalTokens); + + // then: < 를 <= 로 바꾸거나 상수 자릿수가 틀리면 여기서 깨진다. + // 구간 중간값(5천만, 2억 등)만 쓰는 기존 Levels 테스트는 이 회귀를 못 잡는다. + assertThat(level).isEqualTo(expected); + } + } + @Nested @DisplayName("팝업·캡션 실데이터") class FactsAndPop { diff --git a/src/test/java/com/tokenphage/api/feature/sync/controller/SyncControllerTest.java b/src/test/java/com/tokenphage/api/feature/sync/controller/SyncControllerTest.java new file mode 100644 index 0000000..37ca661 --- /dev/null +++ b/src/test/java/com/tokenphage/api/feature/sync/controller/SyncControllerTest.java @@ -0,0 +1,155 @@ +package com.tokenphage.api.feature.sync.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.tokenphage.api.config.SecurityConfig; +import com.tokenphage.api.feature.sync.dto.request.SyncRequest; +import com.tokenphage.api.feature.sync.dto.request.TokenRecordRequest; +import com.tokenphage.api.feature.sync.service.SyncOrchestrator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.BDDMockito.then; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * SyncController 의 HTTP 계약을 검증한다. + *

+ * DTO 제약 자체는 SyncValidationTest 가 Validator 로 직접 검증한다. 여기서 보는 것은 + * 그 제약이 실제 요청 경로에 배선되어 400 으로 나가는지, 그리고 미인증이 401 로 막히는지다. + * 이 배선이 끊기면 잘못된 요청이 서비스까지 내려가 500 으로 바뀐다. + *

+ * SecurityConfig 를 함께 올려 /api/sync 의 authenticated() 규칙까지 검증한다. + * 오케스트레이터는 mock 이라 DB·Redis 는 쓰지 않는다. + */ +@WebMvcTest(SyncController.class) +@Import(SecurityConfig.class) +@TestPropertySource(properties = "badge.jwt-secret=webmvctest-dummy-secret-key-0123456789") +@DisplayName("POST /api/sync HTTP 계약") +class SyncControllerTest { + + private static final String PATH = "/api/sync"; + private static final String VALID_UUID = "550e8400-e29b-41d4-a716-446655440000"; + + @Autowired + private MockMvc mockMvc; + + /** 슬라이스에 ObjectMapper 빈이 없어 직접 만든다. 평범한 record 직렬화라 Spring 설정이 필요 없다. */ + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + @MockitoBean + private SyncOrchestrator syncOrchestrator; + + private String body(String deviceId) throws Exception { + TokenRecordRequest record = new TokenRecordRequest("2026-06-11", "claude-opus-4", 1, 1, 0, 0); + return OBJECT_MAPPER.writeValueAsString(new SyncRequest(deviceId, List.of(record))); + } + + @Nested + @DisplayName("정상 요청") + class Success { + + @Test + @DisplayName("동기화_인증된유효요청_200반환") + void 동기화_인증된유효요청_200반환() throws Exception { + // given + String json = body(VALID_UUID); + + // when / then + mockMvc.perform(post(PATH).with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("필드값 validation") + class Validation { + + @Test + @DisplayName("동기화_deviceId가UUID아님_400반환") + void 동기화_deviceId가UUID아님_400반환() throws Exception { + // given: @Valid 가 빠지면 이 값이 서비스까지 내려가 UUID.fromString 에서 500 이 된다 + String json = body("not-a-uuid"); + + // when / then + mockMvc.perform(post(PATH).with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("동기화_검증실패_오케스트레이터미호출") + void 동기화_검증실패_오케스트레이터미호출() throws Exception { + // given + String json = body("not-a-uuid"); + + // when + mockMvc.perform(post(PATH).with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + + // then: 검증이 서비스 앞에서 끊어야 한다 + then(syncOrchestrator).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("동기화_깨진JSON_400반환") + void 동기화_깨진JSON_400반환() throws Exception { + // given + String malformed = "{\"deviceId\": "; + + // when / then + mockMvc.perform(post(PATH).with(jwt()) + .contentType(MediaType.APPLICATION_JSON) + .content(malformed)) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("인증") + class Authentication { + + @Test + @DisplayName("동기화_미인증_401반환") + void 동기화_미인증_401반환() throws Exception { + // given + String json = body(VALID_UUID); + + // when / then: /api/sync 는 authenticated() 다 + mockMvc.perform(post(PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("동기화_미인증_오케스트레이터미호출") + void 동기화_미인증_오케스트레이터미호출() throws Exception { + // given + String json = body(VALID_UUID); + + // when + mockMvc.perform(post(PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(json)); + + // then + then(syncOrchestrator).shouldHaveNoInteractions(); + } + } +} diff --git a/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncSchemaConstraintTest.java b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncSchemaConstraintTest.java new file mode 100644 index 0000000..1ddd90d --- /dev/null +++ b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncSchemaConstraintTest.java @@ -0,0 +1,121 @@ +package com.tokenphage.api.feature.sync.dto.request; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * sync 입력 DTO 의 제약값이 daily_token_usage 테이블 정의와 수치로 일치하는지 대조한다. + *

+ * 컴파일러가 못 잡는 계약이다 — Bean Validation 애너테이션과 DDL 은 서로를 모른다. + * 한쪽만 단언하면 반대쪽이 바뀔 때 조용히 통과하므로 양쪽 숫자를 함께 읽어 비교한다. + * 마이그레이션 SQL 은 classpath 리소스라 외부 파일 의존이 없다(hermetic). + */ +@DisplayName("sync DTO 제약 <-> DB 스키마 정합성") +class SyncSchemaConstraintTest { + + private static final String V1 = "/db/migration/V1__init.sql"; + private static final String V2 = "/db/migration/V2__add_cache_columns.sql"; + + private static final List TOKEN_COLUMNS = + List.of("input_tok", "output_tok", "cache_read_tok", "cache_create_tok"); + + /** 단일 디바이스·단일 모델이 윤년 내내 상한을 채웠을 때의 행 수. */ + private static final long WORST_CASE_DAYS = 366L; + /** 집계식이 한 행에서 더하는 토큰 필드 수. */ + private static final long TOKEN_FIELDS = 4L; + /** bigint 범위에 남겨둘 안전 여백 배수. */ + private static final long SAFETY_FACTOR = 1_000L; + + private static String sql(String resourcePath) { + try (InputStream in = SyncSchemaConstraintTest.class.getResourceAsStream(resourcePath)) { + assertThat(in).as("마이그레이션 리소스 %s", resourcePath).isNotNull(); + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** DDL 에서 {@code <컬럼> VARCHAR(n)} 의 n 을 뽑는다. 없으면 -1. */ + private static int varcharWidth(String ddl, String column) { + Matcher m = Pattern.compile(column + "\\s+VARCHAR\\((\\d+)\\)", Pattern.CASE_INSENSITIVE).matcher(ddl); + return m.find() ? Integer.parseInt(m.group(1)) : -1; + } + + private static boolean isBigint(String ddl, String column) { + return Pattern.compile(column + "\\s+BIGINT\\b", Pattern.CASE_INSENSITIVE).matcher(ddl).find(); + } + + @Nested + @DisplayName("문자열 컬럼 폭") + class StringWidth { + + @Test + @DisplayName("제약대조_model길이_DDL컬럼폭과일치") + void 제약대조_model길이_DDL컬럼폭과일치() { + // given: daily_token_usage.model 의 실제 컬럼 폭 + int ddlWidth = varcharWidth(sql(V1), "model"); + + // when + int annotated = TokenRecordRequest.MODEL_MAX_LENGTH; + + // then: 애너테이션이 더 크면 DB 에서 22001 로 터지고, 더 작으면 멀쩡한 값을 400 으로 거절한다 + assertThat(ddlWidth).as("V1__init.sql 의 model VARCHAR(n)").isPositive(); + assertThat(annotated).isEqualTo(ddlWidth); + } + } + + @Nested + @DisplayName("수치 컬럼 한계") + class NumericLimit { + + @Test + @DisplayName("제약대조_토큰컬럼4종_전부BIGINT") + void 제약대조_토큰컬럼4종_전부BIGINT() { + // given + String ddl = sql(V1) + "\n" + sql(V2); + + // when / then: INTEGER 로 좁혀지면 TOKEN_MAX 가 컬럼에 들어가지 않는다 + assertThat(TOKEN_COLUMNS) + .allSatisfy(column -> assertThat(isBigint(ddl, column)) + .as("%s 는 BIGINT 여야 한다", column) + .isTrue()); + } + + @Test + @DisplayName("제약대조_토큰상한_1년최악누적도bigint여백내") + void 제약대조_토큰상한_1년최악누적도bigint여백내() { + // given: 한 행의 토큰 4필드가 모두 상한이고 그런 행이 366일 쌓인 경우 + long worstYearlySum = TokenRecordRequest.TOKEN_MAX * TOKEN_FIELDS * WORST_CASE_DAYS; + + // when + long budget = Long.MAX_VALUE / SAFETY_FACTOR; + + // then: 집계 SUM 이 Postgres 22003 을 내지 않도록 1000배 여백을 유지한다 + assertThat(worstYearlySum).isLessThan(budget); + } + + @Test + @DisplayName("제약대조_레코드상한_단일요청최악합계도bigint여백내") + void 제약대조_레코드상한_단일요청최악합계도bigint여백내() { + // given: 한 요청이 상한만큼의 레코드를 담고 각 레코드가 토큰 상한인 경우 + long worstRequestSum = (long) SyncRequest.MAX_RECORDS * TokenRecordRequest.TOKEN_MAX * TOKEN_FIELDS; + + // when + long budget = Long.MAX_VALUE / SAFETY_FACTOR; + + // then + assertThat(worstRequestSum).isLessThan(budget); + } + } +} diff --git a/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java index 50faec6..6278586 100644 --- a/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java +++ b/src/test/java/com/tokenphage/api/feature/sync/dto/request/SyncValidationTest.java @@ -31,6 +31,12 @@ class SyncValidationTest { private static final String VALID_UUID = "550e8400-e29b-41d4-a716-446655440000"; + // 한계값은 DTO 가 단일 출처다. 여기서 복제하면 애너테이션이 바뀌어도 테스트가 못 잡는다. + // DDL 과의 대조는 SchemaConstraintConsistencyTest 가 담당한다. + private static final int MODEL_MAX_LENGTH = TokenRecordRequest.MODEL_MAX_LENGTH; + private static final long TOKEN_MAX = TokenRecordRequest.TOKEN_MAX; + private static final int MAX_RECORDS = SyncRequest.MAX_RECORDS; + @BeforeAll static void setUp() { factory = Validation.buildDefaultValidatorFactory(); @@ -46,6 +52,13 @@ private TokenRecordRequest validRecord() { return new TokenRecordRequest("2026-06-11", "claude-opus-4", 1, 1, 0, 0); } + /** 유효한 레코드 n건. 개수 제약만 검증하도록 각 레코드 자체는 위반이 없게 만든다. */ + private static List records(int n) { + return java.util.stream.IntStream.range(0, n) + .mapToObj(i -> new TokenRecordRequest("2026-06-11", "claude-opus-4", 1, 1, 0, 0)) + .toList(); + } + private static boolean hasViolationOn(Set> violations, String field) { return violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals(field)); } @@ -121,6 +134,32 @@ class SyncReq { // then assertThat(violations).isNotEmpty(); } + + @Test + @DisplayName("records가 상한(10,000건)이면 위반 없음 - 경계") + void 동기화요청_records상한_위반없음() { + // given + SyncRequest req = new SyncRequest(VALID_UUID, records(MAX_RECORDS)); + + // when + var violations = validator.validate(req); + + // then + assertThat(hasViolationOn(violations, "records")).isFalse(); + } + + @Test + @DisplayName("records가 상한을 1건 넘으면 records 위반 - 무제한 역직렬화로 인한 OOM 차단") + void 동기화요청_records상한초과_위반발생() { + // given: 상한이 없으면 Jackson 이 수백만 건을 전부 힙에 올린다 + SyncRequest req = new SyncRequest(VALID_UUID, records(MAX_RECORDS + 1)); + + // when + var violations = validator.validate(req); + + // then + assertThat(hasViolationOn(violations, "records")).isTrue(); + } } @Nested @@ -198,5 +237,66 @@ class RecordReq { // then assertThat(violations).isEmpty(); } + + @Test + @DisplayName("model이 컬럼 폭(80자)이면 위반 없음 - 경계") + void 레코드_model컬럼폭길이_위반없음() { + // given + TokenRecordRequest r = new TokenRecordRequest( + "2026-06-11", "m".repeat(MODEL_MAX_LENGTH), 1, 1, 0, 0); + + // when + var violations = validator.validate(r); + + // then + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("model이 컬럼 폭을 1자 넘으면 model 위반 - DB 500 대신 400으로 막는다") + void 레코드_model컬럼폭초과_위반발생() { + // given: 제약이 없으면 네이티브 INSERT 까지 내려가 Postgres 22001 이 나고, + // saveRecords 가 @Transactional 이라 그 sync 배치 전체가 롤백된다 + TokenRecordRequest r = new TokenRecordRequest( + "2026-06-11", "m".repeat(MODEL_MAX_LENGTH + 1), 1, 1, 0, 0); + + // when + var violations = validator.validate(r); + + // then + assertThat(hasViolationOn(violations, "model")).isTrue(); + } + + @Test + @DisplayName("토큰 4필드가 상한이면 위반 없음 - 경계") + void 레코드_토큰상한_위반없음() { + // given + TokenRecordRequest r = new TokenRecordRequest( + "2026-06-11", "claude", TOKEN_MAX, TOKEN_MAX, TOKEN_MAX, TOKEN_MAX); + + // when + var violations = validator.validate(r); + + // then + assertThat(violations).isEmpty(); + } + + @Test + @DisplayName("토큰 4필드가 상한을 1 넘으면 각 필드 위반 - bigint SUM 오버플로 차단") + void 레코드_토큰상한초과_각필드위반발생() { + // given: 상한이 없으면 Long.MAX 급 값이 저장되고, 이후 SUM(input_tok + output_tok) 이 + // Postgres 22003 을 던져 그 사용자 배지가 영구 500 이 된다 (자가 복구 불가) + long over = TOKEN_MAX + 1; + TokenRecordRequest r = new TokenRecordRequest("2026-06-11", "claude", over, over, over, over); + + // when + var violations = validator.validate(r); + + // then + assertThat(hasViolationOn(violations, "inputTok")).isTrue(); + assertThat(hasViolationOn(violations, "outputTok")).isTrue(); + assertThat(hasViolationOn(violations, "cacheReadTok")).isTrue(); + assertThat(hasViolationOn(violations, "cacheCreateTok")).isTrue(); + } } }