diff --git a/.gitignore b/.gitignore
index b15868d..0d3f3f1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,6 @@ src/test/java/com/tokenphage/api/feature/badge/svg/GpuBadgeLevelsReportTest.java
### 마스코트 export 산출물 — ExportTest가 로컬 재생성. 레포엔 두지 않고 tokenphage-assets로 배포한다 ###
assets/
+
+### 에이전트 하네스 로컬 상태 — 도구가 생성하며 레포와 무관하다 ###
+.omc/
diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java b/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java
index b723592..c3ca2d8 100644
--- a/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java
+++ b/src/main/java/com/tokenphage/api/domain/badge/repository/UserBadgeGrantRepository.java
@@ -13,12 +13,13 @@ public interface UserBadgeGrantRepository
/**
* 배지 사용 가능 여부를 DB 왕복 한 번으로 확인한다.
*
- * 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 EXISTS는 실행되지 않는다.
+ * 자격 필요 여부와 자격 보유를 한 쿼리에서 본다. 공개 배지면 자격 EXISTS는 실행되지 않는다.
* 자격은 github_id 기준이고 요청은 username이라 users를 조인한다.
+ * 사용자 존재 여부(userExists)도 같은 왕복에서 본다.
*
* @param username 배지 주인 GitHub 사용자명 (null 불허)
* @param badgeCode 정리된 배지 코드 (null 불허)
- * @return 사용 가능 여부와 안내 문구. 등록되지 않은 배지 코드면 null
+ * @return 사용 가능 여부·사용자 존재 여부·안내 문구. 등록되지 않은 배지 코드면 null
* @Since 2026-08-10
*/
@Query(value = """
@@ -29,6 +30,11 @@ public interface UserBadgeGrantRepository
WHERE u.username = :username
AND ubg.badge_code = c.code
) AS granted,
+ EXISTS (
+ SELECT 1
+ FROM users u2
+ WHERE u2.username = :username
+ ) AS userExists,
c.display_name AS displayName,
c.locked_message AS lockedMessage
FROM badge_catalog c
diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java b/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java
index 08ad438..66b4752 100644
--- a/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java
+++ b/src/main/java/com/tokenphage/api/domain/badge/repository/projection/BadgeGrantRow.java
@@ -21,4 +21,7 @@ public interface BadgeGrantRow {
/** 자격이 없을 때 보여줄 안내 문구. NULL이면 호출 측이 기본 문구를 쓴다. */
String getLockedMessage();
+
+ /** 배지 주인이 가입된 사용자면 true. primitive다 — EXISTS는 NULL을 반환하지 않는다. */
+ boolean getUserExists();
}
diff --git a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java
index e14008f..7307389 100644
--- a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java
+++ b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantResult.java
@@ -3,11 +3,12 @@
/**
* 배지 사용 가능 여부 확인 결과.
*
- * @param granted 쓸 수 있으면 true
- * @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다
- * @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다
+ * @param granted 쓸 수 있으면 true
+ * @param userExists 배지 주인이 가입된 사용자면 true. false면 받는 쪽이 404로 끊는다
+ * @param title 잠금 안내 제목(배지 이름). 거부일 때만 채워진다
+ * @param message 잠금 안내 문구. null이면 받는 쪽이 기본 문구로 채운다
*/
-public record BadgeGrantResult(boolean granted, String title, String message) {
+public record BadgeGrantResult(boolean granted, boolean userExists, String title, String message) {
/**
* 사용 가능 결과를 만든다.
@@ -18,11 +19,13 @@ public record BadgeGrantResult(boolean granted, String title, String message) {
* @Since 2026-08-10
*/
public static BadgeGrantResult allow() {
- return new BadgeGrantResult(true, null, null);
+ return new BadgeGrantResult(true, true, null, null);
}
/**
* 거부 결과를 안내 문구와 함께 만든다.
+ *
+ * 배지 주인은 가입돼 있고 자격만 없는 상태다. 받는 쪽은 잠금 안내를 그린다.
*
* @param title 배지 이름 (null이면 기본 제목으로 대체)
* @param message 안내 문구 (null이면 기본 문구로 대체)
@@ -30,6 +33,18 @@ public static BadgeGrantResult allow() {
* @Since 2026-08-10
*/
public static BadgeGrantResult deny(String title, String message) {
- return new BadgeGrantResult(false, title, message);
+ return new BadgeGrantResult(false, true, title, message);
+ }
+
+ /**
+ * 가입되지 않은 사용자라는 결과를 만든다.
+ *
+ * 자격 거부와 구분된다. 잠금 안내를 그릴 대상이 아니므로 문구를 싣지 않는다.
+ *
+ * @return 미가입 결과
+ * @Since 2026-09-06
+ */
+ public static BadgeGrantResult userNotFound() {
+ return new BadgeGrantResult(false, false, null, null);
}
}
diff --git a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java
index 2360838..ccf03f3 100644
--- a/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java
+++ b/src/main/java/com/tokenphage/api/domain/badge/service/BadgeGrantService.java
@@ -23,18 +23,25 @@ public class BadgeGrantService {
*
* 판단 기준은 뷰어가 아니라 /badge/{username}의 주인이다.
* 등록되지 않은 코드는 거부한다. 안내 문구 표시는 feature/badge가 정한다.
+ * 가입되지 않은 주인은 자격 거부보다 먼저 가려낸다 — 공개 배지는 미가입이어도 granted가 참이라
+ * granted만으로는 구분되지 않는다. 미가입을 어떤 응답으로 바꿀지는 feature/badge가 정한다.
*
* @param username 배지 주인 GitHub 사용자명 (null 불허)
* @param badgeCode 정리된 배지 코드 (null 불허)
- * @return 사용 가능 여부와 안내 문구
+ * @return 사용 가능 여부·사용자 존재 여부와 안내 문구
* @Since 2026-08-10
*/
public BadgeGrantResult resolveGrant(String username, String badgeCode) {
BadgeGrantRow row = grantRepo.findGrant(username, badgeCode);
if (row == null) {
+ // 조회 결과가 없으면 사용자 존재도 알 수 없다. 미가입으로 단정하지 않고 거부만 한다.
log.info("Badge code not found in catalog, denying: badgeCode={}", badgeCode);
return BadgeGrantResult.deny(null, null);
}
+ if (!row.getUserExists()) {
+ log.info("Badge owner not registered: username={}, badgeCode={}", username, badgeCode);
+ return BadgeGrantResult.userNotFound();
+ }
if (row.getGranted()) {
log.info("Badge grant allow: username={}, badgeCode={}", username, badgeCode);
return BadgeGrantResult.allow();
diff --git a/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java b/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java
index 3b4483d..41e4809 100644
--- a/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java
+++ b/src/main/java/com/tokenphage/api/exception/GlobalExceptionHandler.java
@@ -4,10 +4,17 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotAcceptableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import jakarta.servlet.ServletException;
+
import java.time.format.DateTimeParseException;
import java.util.stream.Collectors;
@@ -93,6 +100,36 @@ public ResponseEntity handleInvalidArgument(DateTimeParseExceptio
.body(new ErrorResponse("INVALID_ARGUMENT", "Request contains an invalid value."));
}
+ /**
+ * 라우팅·프로토콜 단계에서 발생한 요청 오류를 예외가 지정한 4xx로 반환한다.
+ *
+ * 이 예외들은 모두 {@code org.springframework.web.ErrorResponse}를 구현해 상태를 스스로 안다.
+ * 응답 코드·메시지를 그 상태에서 파생하므로 예외를 추가할 때 매핑 표를 고칠 필요가 없다.
+ * 예외 메시지는 내부 구현(정적 리소스 폴백 등)을 노출하므로 로그에만 남긴다.
+ *
+ * @param e 라우팅·프로토콜 단계 예외
+ * @return 예외가 지정한 상태와 그 상태명을 코드로 담은 응답
+ * @Since 2026-09-09
+ */
+ @ExceptionHandler({
+ NoResourceFoundException.class,
+ HttpRequestMethodNotSupportedException.class,
+ HttpMediaTypeNotSupportedException.class,
+ HttpMediaTypeNotAcceptableException.class,
+ MissingServletRequestParameterException.class
+ })
+ public ResponseEntity handleWebRequestException(ServletException e) {
+ // 목록에 ErrorResponse 미구현 예외가 잘못 추가되면 기존 500 경로로 되돌린다
+ if (!(e instanceof org.springframework.web.ErrorResponse errorResponse)) {
+ return handleException(e);
+ }
+ HttpStatus status = HttpStatus.valueOf(errorResponse.getStatusCode().value());
+ log.warn("Request could not be handled: status={}, reason={}", status.value(), e.getMessage());
+ return ResponseEntity
+ .status(status)
+ .body(new ErrorResponse(status.name(), status.getReasonPhrase() + "."));
+ }
+
/**
* 예상치 못한 예외를 처리한다.
*
diff --git a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
index afad0a7..e9b3d38 100644
--- a/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
+++ b/src/main/java/com/tokenphage/api/feature/badge/controller/BadgeController.java
@@ -20,6 +20,7 @@ public class BadgeController {
*
* 배지 주인에게 자격이 없으면 잠금 안내 SVG를 200으로 반환한다.
* 4xx는 깨진 이미지로 보인다. 대신 결과 값을 요청 속성에 넣어 접근 기록에 남긴다.
+ * 다만 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404(BADGE_001)로 끊는다.
*
* @param request 결과 값을 실어 보낼 현재 요청
* @param username 배지를 조회할 GitHub 사용자명
diff --git a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java
index 20faa83..969e414 100644
--- a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java
+++ b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeRenderService.java
@@ -3,8 +3,10 @@
import com.tokenphage.api.domain.badge.BadgeCode;
import com.tokenphage.api.domain.badge.service.BadgeGrantResult;
import com.tokenphage.api.domain.badge.service.BadgeGrantService;
+import com.tokenphage.api.exception.AppException;
import com.tokenphage.api.feature.badge.dto.response.BadgeResponse;
import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse;
+import com.tokenphage.api.feature.badge.exception.BadgeErrorCode;
import com.tokenphage.api.feature.badge.svg.BadgeMode;
import com.tokenphage.api.feature.badge.svg.SvgBuilder;
import com.tokenphage.api.feature.badge.svg.theme.locked.LockedBadgeTheme;
@@ -33,20 +35,16 @@ public class BadgeRenderService {
/**
* 사용자 배지 SVG를 반환한다.
*
- * theme/mode를 등록된 값으로 정규화한 뒤 캐시 키를 구성한다. 정규화로 캐시 키 카디널리티를
- * 유한하게 제한하여, 임의 theme/mode 입력에 의한 Redis 키 무한 증식을 방지한다.
- * 캐시에 값이 있으면 즉시 반환하고, 없으면 DB에서 조회해 SVG를 생성한 뒤 캐시에 저장한다.
- * 캐시 TTL은 설정값 badge.cache-ttl-minutes를 따른다.
- *
- *
- * 자격이 필요한 배지인데 배지 주인에게 자격이 없으면 잠금 안내 SVG를 대신 반환한다.
- * 이때 잠금 SVG는 캐시에 저장하지 않으므로 자격을 부여하면 다음 요청에 즉시 반영된다.
+ * theme/mode를 등록된 값으로 정규화해 임의 입력에 의한 캐시 키 증식을 막는다.
+ * 캐시 적중이면 즉시 반환하고, 아니면 조회·생성 후 badge.cache-ttl-minutes 동안 캐시한다.
+ * 자격이 없으면 잠금 SVG를 반환하며, 이 SVG는 캐시하지 않아 부여가 즉시 반영된다.
+ * 배지 주인이 가입돼 있지 않으면 테마와 무관하게 404로 끊는다.
*
* @param username 배지를 조회할 GitHub 사용자명 (null 불허)
* @param theme 배지 스킨 종류 (미등록 값은 기본 테마로 정규화)
* @param mode 색상 모드 (테마가 지원하지 않는 값은 그 테마의 기본 모드로 정규화)
* @return SVG 문자열과 자격 판정 결과
- * @throws com.tokenphage.api.exception.AppException 사용자가 없을 경우 (BADGE_001)
+ * @throws AppException 배지 주인이 가입되지 않았을 경우 (BADGE_001)
* @Since 2026-05-27
*/
public BadgeSvgResponse getSvg(String username, String theme, String mode) {
@@ -56,6 +54,13 @@ public BadgeSvgResponse getSvg(String username, String theme, String mode) {
// #0. 자격 확인은 캐시 조회보다 먼저. 캐시-자격 불일치를 막고 회수가 즉시 반영된다.
// 잠금 SVG는 캐시하지 않으므로 부여도 즉시 반영된다.
BadgeGrantResult grant = badgeGrantService.resolveGrant(username, normalizedTheme);
+
+ // #0-1. 미가입은 자격 거부보다 먼저 끊는다. 공개 테마와 자격 테마의 응답을 404로 통일한다.
+ if (!grant.userExists()) {
+ log.info("Badge owner not registered, returning 404: username={}", username);
+ throw new AppException(BadgeErrorCode.USER_NOT_FOUND);
+ }
+
if (!grant.granted()) {
log.info("Badge locked, rendering lock notice: username={}, theme={}", username, normalizedTheme);
// 잠금 테마 자신의 지원 집합으로 다시 정규화한다 — 원 테마의 악센트 모드를 잠금 테마는 모른다.
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/BadgeCacheInvalidatorTest.java b/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java
index b02e987..1604af1 100644
--- a/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java
+++ b/src/test/java/com/tokenphage/api/domain/badge/BadgeCacheInvalidatorTest.java
@@ -15,11 +15,11 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
/**
* BadgeCacheInvalidator 의 evict 동작(Redis 키 삭제, SCAN 순회)을 검증한다.
@@ -48,16 +48,16 @@ class Evict {
@SuppressWarnings("unchecked")
Cursor cursor = (Cursor) mock(Cursor.class);
- when(cursor.hasNext()).thenReturn(true, true, false);
- when(cursor.next()).thenReturn(key1, key2);
- when(redis.scan(any(ScanOptions.class))).thenReturn(cursor);
+ given(cursor.hasNext()).willReturn(true, true, false);
+ given(cursor.next()).willReturn(key1, key2);
+ given(redis.scan(any(ScanOptions.class))).willReturn(cursor);
// when
invalidator.evict(username);
// then — SCAN(badge:octocat:*)으로 찾은 실제 키를 일괄 삭제한다
- verify(redis).delete(List.of(key1, key2));
- verify(cursor).close();
+ then(redis).should().delete(List.of(key1, key2));
+ then(cursor).should().close();
}
@Test
@@ -68,15 +68,15 @@ class Evict {
@SuppressWarnings("unchecked")
Cursor cursor = (Cursor) mock(Cursor.class);
- when(cursor.hasNext()).thenReturn(false);
- when(redis.scan(any(ScanOptions.class))).thenReturn(cursor);
+ given(cursor.hasNext()).willReturn(false);
+ given(redis.scan(any(ScanOptions.class))).willReturn(cursor);
// when
invalidator.evict(username);
// then
- verify(redis, never()).delete(anyCollection());
- verify(cursor, times(1)).close();
+ then(redis).should(never()).delete(anyCollection());
+ then(cursor).should(times(1)).close();
}
}
}
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/badge/service/BadgeGrantServiceTest.java b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java
index 6d527ad..108c60e 100644
--- a/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java
+++ b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java
@@ -3,7 +3,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
-import static org.mockito.Mockito.verify;
+import static org.mockito.BDDMockito.then;
import com.tokenphage.api.domain.badge.repository.UserBadgeGrantRepository;
import com.tokenphage.api.domain.badge.repository.projection.BadgeGrantRow;
@@ -41,9 +41,10 @@ void setUp() {
/**
* 가짜 조회 결과를 만든다.
*/
- private BadgeGrantRow row(boolean granted, String displayName, String lockedMessage) {
+ private BadgeGrantRow row(boolean granted, boolean userExists, String displayName, String lockedMessage) {
return new BadgeGrantRow() {
@Override public boolean getGranted() { return granted; }
+ @Override public boolean getUserExists() { return userExists; }
@Override public String getDisplayName() { return displayName; }
@Override public String getLockedMessage() { return lockedMessage; }
};
@@ -58,7 +59,7 @@ class ResolveGrantTest {
void 사용가능여부_누구나쓰는배지_허용() {
// given
given(grantRepo.findGrant(USERNAME, PUBLIC_CODE))
- .willReturn(row(true, "GPU Card", null));
+ .willReturn(row(true, true, "GPU Card", null));
// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE);
@@ -74,7 +75,7 @@ class ResolveGrantTest {
void 사용가능여부_자격필요한배지에자격있음_허용() {
// given
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
- .willReturn(row(true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
+ .willReturn(row(true, true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
@@ -88,13 +89,14 @@ class ResolveGrantTest {
void 사용가능여부_자격필요한배지에자격없음_거부하고안내문구반환() {
// given
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
- .willReturn(row(false, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
+ .willReturn(row(false, true, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
// then
assertThat(result.granted()).isFalse();
+ assertThat(result.userExists()).isTrue();
assertThat(result.title()).isEqualTo("Contributor");
assertThat(result.message()).isEqualTo("PR을 보내고 이 배지를 받아보세요");
}
@@ -105,7 +107,7 @@ class ResolveGrantTest {
// given
// 안내 문구 미등록 배지. 기본 문구 대체는 렌더러 담당.
given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
- .willReturn(row(false, "Contributor", null));
+ .willReturn(row(false, true, "Contributor", null));
// when
BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
@@ -128,6 +130,8 @@ class ResolveGrantTest {
// then
assertThat(result.granted()).isFalse();
+ // 조회 결과가 없으면 사용자 존재를 알 수 없다. 미가입으로 단정해 404를 내지 않는다.
+ assertThat(result.userExists()).isTrue();
assertThat(result.title()).isNull();
assertThat(result.message()).isNull();
}
@@ -137,7 +141,7 @@ class ResolveGrantTest {
void 사용가능여부_호출_받은값이바뀌지않고DB조회로전달됨() {
// given
given(grantRepo.findGrant(anyString(), anyString()))
- .willReturn(row(true, "GPU Card", null));
+ .willReturn(row(true, true, "GPU Card", null));
ArgumentCaptor usernameCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor codeCaptor = ArgumentCaptor.forClass(String.class);
@@ -145,9 +149,48 @@ class ResolveGrantTest {
sut.resolveGrant(USERNAME, PUBLIC_CODE);
// then
- verify(grantRepo).findGrant(usernameCaptor.capture(), codeCaptor.capture());
+ then(grantRepo).should().findGrant(usernameCaptor.capture(), codeCaptor.capture());
assertThat(usernameCaptor.getValue()).isEqualTo(USERNAME);
assertThat(codeCaptor.getValue()).isEqualTo(PUBLIC_CODE);
}
}
+
+ @Nested
+ @DisplayName("가입되지 않은 사용자 판정")
+ class UserNotFoundTest {
+
+ @Test
+ @DisplayName("사용가능여부_미가입사용자_공개배지여도미가입으로판정")
+ void 사용가능여부_미가입사용자_공개배지여도미가입으로판정() {
+ // given
+ // 공개 배지라 쿼리의 granted는 true지만 users에 행이 없어 userExists가 false다
+ given(grantRepo.findGrant(USERNAME, PUBLIC_CODE))
+ .willReturn(row(true, false, "GPU Card", null));
+
+ // when
+ BadgeGrantResult result = sut.resolveGrant(USERNAME, PUBLIC_CODE);
+
+ // then
+ assertThat(result.userExists()).isFalse();
+ assertThat(result.granted()).isFalse();
+ }
+
+ @Test
+ @DisplayName("사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정")
+ void 사용가능여부_미가입사용자_자격필요배지_잠금문구없이미가입으로판정() {
+ // given
+ given(grantRepo.findGrant(USERNAME, PRIVATE_CODE))
+ .willReturn(row(false, false, "Contributor", "PR을 보내고 이 배지를 받아보세요"));
+
+ // when
+ BadgeGrantResult result = sut.resolveGrant(USERNAME, PRIVATE_CODE);
+
+ // then
+ // 미가입이 자격 거부보다 우선한다. 잠금 안내 문구를 싣지 않아 렌더러가 잠금 SVG를 그리지 못한다.
+ assertThat(result.userExists()).isFalse();
+ assertThat(result.granted()).isFalse();
+ assertThat(result.title()).isNull();
+ assertThat(result.message()).isNull();
+ }
+ }
}
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/domain/user/service/UserServiceTest.java b/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java
index 9429893..54db969 100644
--- a/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java
+++ b/src/test/java/com/tokenphage/api/domain/user/service/UserServiceTest.java
@@ -8,7 +8,7 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
-import static org.mockito.Mockito.verify;
+import static org.mockito.BDDMockito.then;
/**
* UserService.saveUser 동작을 검증한다.
@@ -33,6 +33,6 @@ class UserServiceTest {
userService.saveUser(githubId, username);
// then
- verify(userRepo).upsert(githubId, username);
+ then(userRepo).should().upsert(githubId, username);
}
}
diff --git a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java
index 3e851a7..1aeb729 100644
--- a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java
+++ b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerTest.java
@@ -3,19 +3,22 @@
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpInputMessage;
+import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.time.format.DateTimeParseException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
/**
* GlobalExceptionHandler의 예외 → HTTP 응답 매핑을 검증한다.
@@ -31,8 +34,8 @@ class GlobalExceptionHandlerTest {
// given
MethodArgumentNotValidException ex = mock(MethodArgumentNotValidException.class);
BindingResult bindingResult = mock(BindingResult.class);
- when(ex.getBindingResult()).thenReturn(bindingResult);
- when(bindingResult.getFieldErrors()).thenReturn(List.of(
+ given(ex.getBindingResult()).willReturn(bindingResult);
+ given(bindingResult.getFieldErrors()).willReturn(List.of(
new FieldError("syncRequest", "deviceId", "deviceId must be a valid UUID")));
// when
@@ -76,4 +79,39 @@ class GlobalExceptionHandlerTest {
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().code()).isEqualTo("INVALID_ARGUMENT");
}
+
+ @Test
+ @DisplayName("라우팅 미스(NoResourceFoundException) → 404 NOT_FOUND, 예외 메시지 비노출")
+ void 라우팅미스_404와NOT_FOUND반환() {
+ // given
+ NoResourceFoundException ex = new NoResourceFoundException(HttpMethod.GET, "/badge/", "badge");
+
+ // when
+ ResponseEntity response = handler.handleWebRequestException(ex);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().code()).isEqualTo("NOT_FOUND");
+ assertThat(response.getBody().message()).isEqualTo("Not Found.");
+ // 예외 메시지("No static resource ...")는 내부 구현이라 응답에 실리지 않는다
+ assertThat(response.getBody().message()).doesNotContain("static resource");
+ }
+
+ @Test
+ @DisplayName("메서드 불일치(HttpRequestMethodNotSupportedException) → 405 METHOD_NOT_ALLOWED")
+ void 메서드불일치_405와METHOD_NOT_ALLOWED반환() {
+ // given
+ HttpRequestMethodNotSupportedException ex =
+ new HttpRequestMethodNotSupportedException("POST", List.of("GET"));
+
+ // when
+ ResponseEntity response = handler.handleWebRequestException(ex);
+
+ // then
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.METHOD_NOT_ALLOWED);
+ assertThat(response.getBody()).isNotNull();
+ assertThat(response.getBody().code()).isEqualTo("METHOD_NOT_ALLOWED");
+ assertThat(response.getBody().message()).isEqualTo("Method Not Allowed.");
+ }
}
diff --git a/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java
new file mode 100644
index 0000000..294cb92
--- /dev/null
+++ b/src/test/java/com/tokenphage/api/exception/GlobalExceptionHandlerWebMvcTest.java
@@ -0,0 +1,124 @@
+package com.tokenphage.api.exception;
+
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.tokenphage.api.config.SecurityConfig;
+import com.tokenphage.api.feature.badge.controller.BadgeController;
+import com.tokenphage.api.feature.badge.exception.BadgeErrorCode;
+import com.tokenphage.api.feature.badge.service.BadgeRenderService;
+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.test.context.TestPropertySource;
+import org.springframework.test.context.bean.override.mockito.MockitoBean;
+import org.springframework.test.web.servlet.MockMvc;
+
+/**
+ * 라우팅·프로토콜 단계의 예외가 올바른 4xx로 나가는지 검증한다.
+ *
+ * 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 extends ConstraintViolation>> 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();
+ }
}
}
diff --git a/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java b/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java
index a9aaeaa..9d3719a 100644
--- a/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java
+++ b/src/test/java/com/tokenphage/api/feature/sync/service/SyncOrchestratorTest.java
@@ -19,11 +19,12 @@
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
-import static org.mockito.Mockito.doThrow;
+import static org.mockito.BDDMockito.then;
+import static org.mockito.BDDMockito.willThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.when;
import static org.mockito.Mockito.never;
-import static org.mockito.Mockito.verify;
/**
* SyncOrchestrator.sync 의 조율 흐름(사용자 저장 → 레코드 저장 → 배지 캐시 무효화)과
@@ -81,9 +82,9 @@ class Sync {
// then
InOrder order =
inOrder(userService, tokenUsageRecordService, badgeCacheInvalidator);
- order.verify(userService).saveUser(GITHUB_ID, USERNAME);
- order.verify(tokenUsageRecordService).saveRecords(jwt, req);
- order.verify(badgeCacheInvalidator).evict(USERNAME);
+ then(userService).should(order).saveUser(GITHUB_ID, USERNAME);
+ then(tokenUsageRecordService).should(order).saveRecords(jwt, req);
+ then(badgeCacheInvalidator).should(order).evict(USERNAME);
order.verifyNoMoreInteractions();
}
@@ -97,9 +98,9 @@ class Sync {
assertThatCode(() -> syncOrchestrator.sync(jwt, req)).doesNotThrowAnyException();
InOrder order =
inOrder(userService, tokenUsageRecordService, badgeCacheInvalidator);
- order.verify(userService).saveUser(GITHUB_ID, USERNAME);
- order.verify(tokenUsageRecordService).saveRecords(jwt, req);
- order.verify(badgeCacheInvalidator).evict(USERNAME);
+ then(userService).should(order).saveUser(GITHUB_ID, USERNAME);
+ then(tokenUsageRecordService).should(order).saveRecords(jwt, req);
+ then(badgeCacheInvalidator).should(order).evict(USERNAME);
}
}
@@ -112,8 +113,8 @@ class SyncFailure {
void 동기화_레코드저장실패_후속호출없이예외전파() {
// given
SyncRequest req = requestWithOneRecord();
- doThrow(new RuntimeException("DB write failed"))
- .when(tokenUsageRecordService).saveRecords(jwt, req);
+ willThrow(new RuntimeException("DB write failed"))
+ .given(tokenUsageRecordService).saveRecords(jwt, req);
// when / then
assertThatThrownBy(() -> syncOrchestrator.sync(jwt, req))
@@ -121,8 +122,8 @@ class SyncFailure {
.hasMessageContaining("DB write failed");
// then: saveUser는 실패 이전이므로 호출됨, 이후 단계는 호출되지 않아야 함
- verify(userService).saveUser(GITHUB_ID, USERNAME);
- verify(badgeCacheInvalidator, never()).evict(USERNAME);
+ then(userService).should().saveUser(GITHUB_ID, USERNAME);
+ then(badgeCacheInvalidator).should(never()).evict(USERNAME);
}
}
}
diff --git a/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java b/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java
index 116abd3..1661ed4 100644
--- a/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java
+++ b/src/test/java/com/tokenphage/api/feature/sync/service/TokenUsageRecordServiceTest.java
@@ -24,12 +24,11 @@
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
/**
* TokenUsageRecordService.saveRecords의 레코드 저장(upsert) 흐름과 입력 파싱 실패를 검증한다.
@@ -55,7 +54,7 @@ class SaveRecordsSuccess {
void 레코드저장_정상2건_upsert2회호출() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
TokenRecordRequest r1 = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L);
TokenRecordRequest r2 = new TokenRecordRequest("2026-06-02", "claude-sonnet", 1L, 2L, 3L, 4L);
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r1, r2));
@@ -64,7 +63,7 @@ class SaveRecordsSuccess {
service.saveRecords(jwt, req);
// then
- verify(tokenRepo, times(2)).upsertRecord(
+ then(tokenRepo).should(times(2)).upsertRecord(
anyLong(), any(UUID.class), any(LocalDate.class), anyString(),
anyLong(), anyLong(), anyLong(), anyLong());
}
@@ -74,7 +73,7 @@ class SaveRecordsSuccess {
void 레코드저장_첫레코드인자_정확히전달() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
TokenRecordRequest r1 = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L);
TokenRecordRequest r2 = new TokenRecordRequest("2026-06-02", "claude-sonnet", 1L, 2L, 3L, 4L);
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r1, r2));
@@ -91,7 +90,7 @@ class SaveRecordsSuccess {
ArgumentCaptor outputCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor cacheReadCaptor = ArgumentCaptor.forClass(Long.class);
ArgumentCaptor cacheCreateCaptor = ArgumentCaptor.forClass(Long.class);
- verify(tokenRepo, times(2)).upsertRecord(
+ then(tokenRepo).should(times(2)).upsertRecord(
githubIdCaptor.capture(),
deviceIdCaptor.capture(),
dateCaptor.capture(),
@@ -121,14 +120,14 @@ class SaveRecordsBoundary {
void 레코드저장_빈목록_호출없음() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of());
// when
service.saveRecords(jwt, req);
// then
- verify(tokenRepo, never()).upsertRecord(
+ then(tokenRepo).should(never()).upsertRecord(
anyLong(), any(UUID.class), any(LocalDate.class), anyString(),
anyLong(), anyLong(), anyLong(), anyLong());
}
@@ -138,7 +137,7 @@ class SaveRecordsBoundary {
void 레코드저장_단건_정확히1회호출() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
TokenRecordRequest only = new TokenRecordRequest("2026-06-01", "claude-opus", 5L, 6L, 7L, 8L);
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(only));
@@ -146,7 +145,7 @@ class SaveRecordsBoundary {
service.saveRecords(jwt, req);
// then
- verify(tokenRepo, times(1)).upsertRecord(
+ then(tokenRepo).should(times(1)).upsertRecord(
eq(-42L),
eq(UUID.fromString(VALID_DEVICE_ID)),
eq(LocalDate.parse("2026-06-01")),
@@ -167,14 +166,14 @@ class SaveRecordsFailure {
void 레코드저장_잘못된UUID_예외발생() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
TokenRecordRequest r = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L);
SyncRequest req = new SyncRequest("not-a-valid-uuid", List.of(r));
// when / then
assertThatThrownBy(() -> service.saveRecords(jwt, req))
.isInstanceOf(IllegalArgumentException.class);
- verifyNoInteractions(tokenRepo);
+ then(tokenRepo).shouldHaveNoInteractions();
}
@Test
@@ -182,14 +181,14 @@ class SaveRecordsFailure {
void 레코드저장_잘못된날짜_예외발생() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("-42");
+ given(jwt.getSubject()).willReturn("-42");
TokenRecordRequest badDate = new TokenRecordRequest("2026-13-99", "claude-opus", 10L, 20L, 30L, 40L);
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(badDate));
// when / then
assertThatThrownBy(() -> service.saveRecords(jwt, req))
.isInstanceOf(DateTimeParseException.class);
- verifyNoInteractions(tokenRepo);
+ then(tokenRepo).shouldHaveNoInteractions();
}
@Test
@@ -197,14 +196,14 @@ class SaveRecordsFailure {
void 레코드저장_subject비숫자_예외발생() {
// given
Jwt jwt = mock(Jwt.class);
- when(jwt.getSubject()).thenReturn("not-a-number");
+ given(jwt.getSubject()).willReturn("not-a-number");
TokenRecordRequest r = new TokenRecordRequest("2026-06-01", "claude-opus", 10L, 20L, 30L, 40L);
SyncRequest req = new SyncRequest(VALID_DEVICE_ID, List.of(r));
// when / then
assertThatThrownBy(() -> service.saveRecords(jwt, req))
.isInstanceOf(NumberFormatException.class);
- verifyNoInteractions(tokenRepo);
+ then(tokenRepo).shouldHaveNoInteractions();
}
}
}
diff --git a/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java b/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java
index fbf6496..0cedc58 100644
--- a/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java
+++ b/src/test/java/com/tokenphage/api/integration/UserBadgeGrantRepositoryIntegrationTest.java
@@ -155,7 +155,7 @@ class RequireGrant {
@Test
@DisplayName("SQL 별칭이 BadgeGrantRow getter에 매핑된다")
- void 자격조회_projection별칭_displayName과lockedMessage매핑() {
+ void 자격조회_projection별칭_displayName과lockedMessage와userExists매핑() {
// given
insertPrivateBadge();
insertUser(GITHUB_ID, USERNAME);
@@ -163,10 +163,44 @@ class RequireGrant {
// when
BadgeGrantRow row = repository.findGrant(USERNAME, PRIVATE_CODE);
- // then: alias displayName/lockedMessage 가 getter로 바인딩된다 (불일치 시 null)
+ // then: alias displayName/lockedMessage/userExists 가 getter로 바인딩된다 (불일치 시 null)
assertThat(row).isNotNull();
assertThat(row.getDisplayName()).isEqualTo(DISPLAY_NAME);
assertThat(row.getLockedMessage()).isEqualTo(LOCKED_MESSAGE);
+ assertThat(row.getUserExists()).isTrue();
+ }
+ }
+
+ @Nested
+ @DisplayName("findGrant() - 사용자 존재 여부")
+ class UserExistence {
+
+ @Test
+ @DisplayName("미가입 사용자는 공개 배지여도 userExists가 false다")
+ void 자격조회_미가입사용자_userExists거짓() {
+ // given: users에 행을 넣지 않는다 (미가입 상태)
+
+ // when
+ BadgeGrantRow row = repository.findGrant(USERNAME, PUBLIC_CODE);
+
+ // then: 공개 배지라 granted는 true로 남고, 사용자 존재는 별도 컬럼으로 구분된다
+ assertThat(row).isNotNull();
+ assertThat(row.getGranted()).isTrue();
+ assertThat(row.getUserExists()).isFalse();
+ }
+
+ @Test
+ @DisplayName("가입 사용자는 userExists가 true다")
+ void 자격조회_가입사용자_userExists참() {
+ // given
+ insertUser(GITHUB_ID, USERNAME);
+
+ // when
+ BadgeGrantRow row = repository.findGrant(USERNAME, PUBLIC_CODE);
+
+ // then: users에 행이 있으면 EXISTS가 참이다
+ assertThat(row).isNotNull();
+ assertThat(row.getUserExists()).isTrue();
}
}
}