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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<TokenRecordRequest> records
) {}
) {

/**
* 한 요청의 레코드 상한. 1년치 x 모델 10종 약 3,650 의 여유분이다.
* JSON 바디는 톰캣 maxPostSize 대상이 아니라 이 제약이 유일한 방어선이다.
*/
public static final int MAX_RECORDS = 10_000;
}
Original file line number Diff line number Diff line change
@@ -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(

Expand All @@ -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배다.
* <p>
* 상한을 두는 이유는 집계 SUM 의 bigint 오버플로(Postgres 22003) 차단이다.
* 한 요청이 레코드 상한을 꽉 채우고 모든 필드가 이 값이어도 4e15 로
* bigint 범위(9.2e18)에 2000배 이상 여백이 남는다.
* 여백 검증은 SyncSchemaConstraintTest 가 담당한다.
*/
public static final long TOKEN_MAX = 100_000_000_000L;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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반환() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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반환() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading