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로 나가는지 검증한다. + *

+ * 실제 디스패치를 태워 핸들러 도달 경로를 확인한다. 컨트롤러는 라우팅을 태우기 위한 발판이다. + */ +@WebMvcTest(BadgeController.class) +@Import(SecurityConfig.class) +@TestPropertySource(properties = "badge.jwt-secret=webmvctest-dummy-secret-key-0123456789") +@DisplayName("GlobalExceptionHandler 디스패치 계약") +class GlobalExceptionHandlerWebMvcTest { + + private static final String USERNAME = "octocat"; + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private BadgeRenderService badgeRenderService; + + @Nested + @DisplayName("라우팅 미스") + class RoutingMiss { + + @Test + @DisplayName("라우팅미스_슬래시로끝나는배지경로_404와NOT_FOUND반환") + void 라우팅미스_슬래시로끝나는배지경로_404와NOT_FOUND반환() throws Exception { + // given + // 어떤 핸들러에도 매칭되지 않아 정적 리소스 폴백으로 흘러간다 + + // when + // then + mockMvc.perform(get("/badge/")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")) + .andExpect(jsonPath("$.message").value("Not Found.")); + } + + @Test + @DisplayName("라우팅미스_username없는배지경로_404와NOT_FOUND반환") + void 라우팅미스_username없는배지경로_404와NOT_FOUND반환() throws Exception { + // given + + // when + // then + mockMvc.perform(get("/badge")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")); + } + + @Test + @DisplayName("라우팅미스_username뒤슬래시_404와NOT_FOUND반환") + void 라우팅미스_username뒤슬래시_404와NOT_FOUND반환() throws Exception { + // given + // /badge/{username} 은 매칭되지만 뒤에 슬래시가 붙으면 매칭되지 않는다 + + // when + // then + mockMvc.perform(get("/badge/{username}/", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("NOT_FOUND")); + } + } + + @Nested + @DisplayName("프로토콜 불일치") + class ProtocolMismatch { + + @Test + @DisplayName("메서드불일치_배지경로에POST_405와METHOD_NOT_ALLOWED반환") + void 메서드불일치_배지경로에POST_405와METHOD_NOT_ALLOWED반환() throws Exception { + // given + // 경로는 매칭되지만 GET만 등록돼 있다 + + // when + // then + mockMvc.perform(post("/badge/{username}", USERNAME)) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.code").value("METHOD_NOT_ALLOWED")); + } + } + + @Nested + @DisplayName("기존 계약 회귀 방지") + class NoRegression { + + @Test + @DisplayName("비즈니스예외_미가입사용자_기존BADGE_001계약유지") + void 비즈니스예외_미가입사용자_기존BADGE_001계약유지() throws Exception { + // given + // 새 핸들러가 handleAppException 을 가리면 안 된다 + given(badgeRenderService.getSvg(USERNAME, "gpu", "light")) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + + // when + // then + mockMvc.perform(get("/badge/{username}", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BADGE_001")) + .andExpect(jsonPath("$.message").value("User not found.")); + } + } +} diff --git a/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java b/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java index 948db2e..4679062 100644 --- a/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java +++ b/src/test/java/com/tokenphage/api/feature/audit/aspect/RequestAuditAspectTest.java @@ -26,8 +26,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** @@ -77,7 +78,7 @@ void tearDown() { @DisplayName("캡처_성공응답_상태와기능정보기록") void 캡처_성공응답_상태와기능정보기록() throws Throwable { // given - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -100,7 +101,7 @@ void tearDown() { // given // 200으로 응답하지만 도메인상 거부인 경우(자격 없는 배지)를 구분하기 위한 통로다. currentRequest().setAttribute(AuditOutcome.ATTRIBUTE_KEY, AuditOutcome.BADGE_GRANT_DENIED); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -117,7 +118,7 @@ void tearDown() { // given // AuditOutcome 타입만 받는다. 문자열을 심어도 무시하고 기본값으로 떨어져야 한다. currentRequest().setAttribute(AuditOutcome.ATTRIBUTE_KEY, "badge_grant_denied"); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -130,7 +131,7 @@ void tearDown() { @DisplayName("캡처_결과미지정_success로기록") void 캡처_결과미지정_success로기록() throws Throwable { // given - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -147,7 +148,7 @@ void tearDown() { MockHttpServletRequest request = currentRequest(); request.setRequestURI("/badge/example-user"); request.setQueryString("theme=contributor&mode=dark"); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -165,7 +166,7 @@ void tearDown() { MockHttpServletRequest request = currentRequest(); request.setRequestURI("/badge/example-user"); request.setQueryString("theme=" + "x".repeat(400)); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -186,7 +187,7 @@ private MockHttpServletRequest currentRequest() { @DisplayName("캡처_AppException_에러코드상태와코드기록후재전파") void 캡처_AppException_에러코드상태와코드기록후재전파() throws Throwable { // given — gist 도용 시나리오(401, AUTH_007) - when(joinPoint.proceed()).thenThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); + given(joinPoint.proceed()).willThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); // when / then — 예외는 그대로 재전파되어야 한다 assertThatThrownBy(() -> aspect.audit(joinPoint)).isInstanceOf(AppException.class); @@ -200,7 +201,7 @@ private MockHttpServletRequest currentRequest() { @DisplayName("캡처_미인증요청_식별필드null") void 캡처_미인증요청_식별필드null() throws Throwable { // given — SecurityContext 미설정 - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -222,7 +223,7 @@ private MockHttpServletRequest currentRequest() { .build(); SecurityContextHolder.getContext().setAuthentication( new UsernamePasswordAuthenticationToken(jwt, null)); - when(joinPoint.proceed()).thenReturn(ResponseEntity.ok().build()); + given(joinPoint.proceed()).willReturn(ResponseEntity.ok().build()); // when aspect.audit(joinPoint); @@ -235,7 +236,7 @@ private MockHttpServletRequest currentRequest() { private RequestAuditCommand captureRecorded() { ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditCommand.class); - verify(requestAuditService).record(captor.capture()); + then(requestAuditService).should().record(captor.capture()); return captor.getValue(); } } 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/audit/service/RequestAuditServiceTest.java b/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java index 3d265b0..ea06bde 100644 --- a/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/audit/service/RequestAuditServiceTest.java @@ -14,8 +14,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; /** * RequestAuditService.record 의 매핑 위임과 best-effort 예외 처리를 검증한다(실 DB 미접촉). @@ -44,7 +44,7 @@ class RequestAuditServiceTest { // then ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getFeature()).isEqualTo("auth"); assertThat(saved.getAction()).isEqualTo("verify"); @@ -66,7 +66,7 @@ class RequestAuditServiceTest { RequestAuditCommand command = new RequestAuditCommand( "sync", "sync", "POST", "/api/sync", 500, 10, "203.0.113.8", -1002L, "octocat", "tokenphage-cli/1.0", "error"); - doThrow(new RuntimeException("db down")).when(auditLogRepo).save(any()); + willThrow(new RuntimeException("db down")).given(auditLogRepo).save(any()); // when / then — best-effort: 예외를 전파하지 않는다 assertThatCode(() -> requestAuditService.record(command)).doesNotThrowAnyException(); @@ -87,7 +87,7 @@ class RequestAuditServiceTest { // then — 컬럼 한계(255)로 잘려 저장된다(INSERT 실패·유실 방지) ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getUserAgent()).hasSize(255); assertThat(saved.getRequestPath()).hasSize(255); @@ -106,7 +106,7 @@ class RequestAuditServiceTest { // then ArgumentCaptor captor = ArgumentCaptor.forClass(RequestAuditLog.class); - verify(auditLogRepo).save(captor.capture()); + then(auditLogRepo).should().save(captor.capture()); RequestAuditLog saved = captor.getValue(); assertThat(saved.getGithubId()).isNull(); assertThat(saved.getUsername()).isNull(); diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java index dbe62eb..4cf8aa2 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceTest.java @@ -28,11 +28,10 @@ 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.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** * AuthService 의 createChallenge "저장 동작"과 verify() 플로우를 검증한다. @@ -60,7 +59,7 @@ void setUp() { jwtIssuer = mock(JwtIssuer.class); redis = mock(StringRedisTemplate.class); valueOps = mock(ValueOperations.class); - when(redis.opsForValue()).thenReturn(valueOps); + given(redis.opsForValue()).willReturn(valueOps); // 생성자 인자 순서 = 필드 선언 순서: UserService, GistVerificationService, JwtIssuer, StringRedisTemplate authService = new AuthService(userService, gistVerification, jwtIssuer, redis); @@ -85,7 +84,7 @@ class CreateChallenge { assertThat(response.challenge()).startsWith("tknphg_"); assertThat(response.expiresAt()).isNotNull(); // Redis set 이 키 prefix + TTL 과 함께 호출됐는지 확인 - verify(valueOps).set( + then(valueOps).should().set( eq("auth:challenge:octocat"), eq(response.challenge()), eq(Duration.ofMinutes(TTL_MINUTES)) @@ -103,7 +102,7 @@ class CreateChallenge { authService.createChallenge(req); // then: 캡처한 키가 소문자화된 형태인지 확인 - verify(valueOps).set(keyCaptor.capture(), anyString(), any(Duration.class)); + then(valueOps).should().set(keyCaptor.capture(), anyString(), any(Duration.class)); assertThat(keyCaptor.getValue()).isEqualTo("auth:challenge:octocat"); } @@ -113,14 +112,14 @@ class CreateChallenge { // given: 동일 사용자의 기존 challenge 가 Redis 에 남아 있다 (검증 실패 후 재시도 상황) ChallengeRequest req = new ChallengeRequest("octocat"); String existing = "tknphg_existing0challenge"; - when(valueOps.get("auth:challenge:octocat")).thenReturn(existing); + given(valueOps.get("auth:challenge:octocat")).willReturn(existing); // when ChallengeResponse response = authService.createChallenge(req); // then: 반환 challenge 가 기존 값과 동일하고, set 은 같은 값 + 갱신된 TTL 로 호출된다 assertThat(response.challenge()).isEqualTo(existing); - verify(valueOps).set( + then(valueOps).should().set( eq("auth:challenge:octocat"), eq(existing), eq(Duration.ofMinutes(TTL_MINUTES)) @@ -137,7 +136,7 @@ class Verify { void 검증_챌린지없음_CHALLENGE_EXPIRED() { // given: get 이 null 을 반환하도록 stub VerifyRequest req = new VerifyRequest("octocat", "gist-123"); - when(valueOps.get("auth:challenge:octocat")).thenReturn(null); + given(valueOps.get("auth:challenge:octocat")).willReturn(null); // when & then assertThatThrownBy(() -> authService.verify(req)) @@ -146,8 +145,10 @@ class Verify { .isEqualTo(AuthErrorCode.CHALLENGE_EXPIRED); // gist 검증/저장/발급 단계는 진입하지 않아야 한다 - verifyNoInteractions(gistVerification, jwtIssuer, userService); - verify(redis, never()).delete(anyString()); + then(gistVerification).shouldHaveNoInteractions(); + then(jwtIssuer).shouldHaveNoInteractions(); + then(userService).shouldHaveNoInteractions(); + then(redis).should(never()).delete(anyString()); } @ParameterizedTest(name = "무효 입력: username=\"{0}\", gistId=\"{1}\"") @@ -171,8 +172,10 @@ class Verify { .isEqualTo(AuthErrorCode.INVALID_USERNAME); // 어떤 의존성도 사용되지 않아야 한다 (Redis get 도 호출 안 됨) - verifyNoInteractions(gistVerification, jwtIssuer, userService); - verify(valueOps, never()).get(anyString()); + then(gistVerification).shouldHaveNoInteractions(); + then(jwtIssuer).shouldHaveNoInteractions(); + then(userService).shouldHaveNoInteractions(); + then(valueOps).should(never()).get(anyString()); } @Test @@ -188,9 +191,9 @@ class Verify { new GistOwnerResponse(-42L, "octocat"), null ); - when(valueOps.get("auth:challenge:octocat")).thenReturn(challenge); - when(gistVerification.verify(req, challenge)).thenReturn(gist); - when(jwtIssuer.issue(-42L, "octocat")).thenReturn(issuedToken); + given(valueOps.get("auth:challenge:octocat")).willReturn(challenge); + given(gistVerification.verify(req, challenge)).willReturn(gist); + given(jwtIssuer.issue(-42L, "octocat")).willReturn(issuedToken); // when TokenResponse response = authService.verify(req); @@ -200,9 +203,9 @@ class Verify { assertThat(response.githubId()).isEqualTo(-42L); assertThat(response.username()).isEqualTo("octocat"); // Redis 챌린지 삭제 + 사용자 저장 + JWT 발급 호출 검증 - verify(redis).delete("auth:challenge:octocat"); - verify(userService).saveUser(-42L, "octocat"); - verify(jwtIssuer).issue(-42L, "octocat"); + then(redis).should().delete("auth:challenge:octocat"); + then(userService).should().saveUser(-42L, "octocat"); + then(jwtIssuer).should().issue(-42L, "octocat"); } @Test @@ -211,9 +214,9 @@ class Verify { // given: gistVerification 이 OWNER_MISMATCH 를 던지도록 stub VerifyRequest req = new VerifyRequest("octocat", "gist-123"); String challenge = "tknphg_deadbeefcafe1234"; - when(valueOps.get("auth:challenge:octocat")).thenReturn(challenge); - when(gistVerification.verify(req, challenge)) - .thenThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); + given(valueOps.get("auth:challenge:octocat")).willReturn(challenge); + given(gistVerification.verify(req, challenge)) + .willThrow(new AppException(AuthErrorCode.OWNER_MISMATCH)); // when & then assertThatThrownBy(() -> authService.verify(req)) @@ -222,9 +225,9 @@ class Verify { .isEqualTo(AuthErrorCode.OWNER_MISMATCH); // 검증 실패 이후 단계는 수행되지 않아야 한다 - verify(redis, never()).delete(anyString()); - verify(userService, never()).saveUser(anyLong(), anyString()); - verify(jwtIssuer, never()).issue(anyLong(), anyString()); + then(redis).should(never()).delete(anyString()); + then(userService).should(never()).saveUser(anyLong(), anyString()); + then(jwtIssuer).should(never()).issue(anyLong(), anyString()); } } } diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java index 78b77d5..3337481 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/AuthServiceUsernameValidationTest.java @@ -12,8 +12,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * GitHub 사용자명 정책 검증을, 공개 진입점 createChallenge 를 통해 확인하는 테스트. @@ -71,7 +71,7 @@ class AuthServiceUsernameValidationTest { @SuppressWarnings("unchecked") private StringRedisTemplate redisMock() { StringRedisTemplate redis = mock(StringRedisTemplate.class); - when(redis.opsForValue()).thenReturn(mock(ValueOperations.class)); + given(redis.opsForValue()).willReturn(mock(ValueOperations.class)); return redis; } } diff --git a/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java b/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java index 8d08655..8a32eac 100644 --- a/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/auth/service/GistVerificationServiceTest.java @@ -23,8 +23,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** * GistVerificationService.verify 의 분기별 성공/실패 및 경계 동작을 검증하는 테스트. @@ -53,14 +53,14 @@ void setUp() { /** Gist API 종단 body() 호출이 주어진 응답을 반환하도록 stub 한다. */ private void stubGistResponse(GistResponse response) { - when(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) - .thenReturn(response); + given(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) + .willReturn(response); } /** Gist API 종단 body() 호출이 주어진 예외를 던지도록 stub 한다. */ private void stubGistThrows(Throwable throwable) { - when(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) - .thenThrow(throwable); + given(gistRestClient.get().uri(anyString(), (Object) any()).retrieve().body(GistResponse.class)) + .willThrow(throwable); } /** 공개+소유자일치+검증파일에 challenge 가 담긴 정상 Gist 응답을 만든다. */ diff --git a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java index 586f040..b6da80b 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java @@ -5,15 +5,18 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; +import static org.mockito.BDDMockito.then; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.tokenphage.api.audit.AuditOutcome; import com.tokenphage.api.config.SecurityConfig; +import com.tokenphage.api.exception.AppException; +import com.tokenphage.api.feature.badge.exception.BadgeErrorCode; import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse; import com.tokenphage.api.feature.badge.service.BadgeRenderService; import org.junit.jupiter.api.DisplayName; @@ -107,6 +110,22 @@ class ResponseTest { .andExpect(content().string(LOCKED_SVG)); } + @Test + @DisplayName("배지조회_미가입_404와BADGE_001본문반환") + void 배지조회_미가입_404와BADGE_001본문반환() throws Exception { + // given + // 미가입은 자격 거부(200 잠금 SVG)와 달리 상태코드로 구분된다. + given(badgeRenderService.getSvg(USERNAME, "gpu", "light")) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + + // when + // then + mockMvc.perform(get("/badge/{username}", USERNAME)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("BADGE_001")) + .andExpect(jsonPath("$.message").value("User not found.")); + } + @Test @DisplayName("배지조회_정상_svg타입과no캐시헤더반환") void 배지조회_정상_svg타입과no캐시헤더반환() throws Exception { @@ -137,7 +156,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(eq(USERNAME), themeCaptor.capture(), modeCaptor.capture()); + then(badgeRenderService).should().getSvg(eq(USERNAME), themeCaptor.capture(), modeCaptor.capture()); assertThat(themeCaptor.getValue()).isEqualTo("gpu"); assertThat(modeCaptor.getValue()).isEqualTo("light"); } @@ -156,7 +175,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(USERNAME, "grass-claude", "dark"); + then(badgeRenderService).should().getSvg(USERNAME, "grass-claude", "dark"); } @Test @@ -174,7 +193,7 @@ class ResponseTest { .andExpect(status().isOk()); // then - verify(badgeRenderService).getSvg(USERNAME, "beta-tester", "green"); + then(badgeRenderService).should().getSvg(USERNAME, "beta-tester", "green"); } } diff --git a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java index de6d73c..832fa8c 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeQueryServiceTest.java @@ -31,12 +31,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.within; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; /** * BadgeQueryService.query / calcCacheHitRate 검증: 사용자 미존재 예외, 정상 응답 조립, @@ -88,16 +88,16 @@ private User user() { /** DailyUsageRow 프로젝션 mock (날짜/총합 getter stub). */ private DailyUsageRow dailyRow(String date, long total) { DailyUsageRow row = mock(DailyUsageRow.class); - when(row.getDate()).thenReturn(date); - when(row.getTotal()).thenReturn(total); + given(row.getDate()).willReturn(date); + given(row.getTotal()).willReturn(total); return row; } /** ModelUsageRow 프로젝션 mock (모델/총합 getter stub). */ private ModelUsageRow modelRow(String model, long total) { ModelUsageRow row = mock(ModelUsageRow.class); - when(row.getModel()).thenReturn(model); - when(row.getTotal()).thenReturn(total); + given(row.getModel()).willReturn(model); + given(row.getTotal()).willReturn(total); return row; } @@ -119,7 +119,7 @@ class Query_UserLookup { @DisplayName("배지조회_사용자없음_USER_NOT_FOUND예외") void 배지조회_사용자없음_USER_NOT_FOUND예외() { // given - when(userRepo.findByUsername("ghost")).thenReturn(Optional.empty()); + given(userRepo.findByUsername("ghost")).willReturn(Optional.empty()); // when // then @@ -128,7 +128,7 @@ class Query_UserLookup { .satisfies(ex -> assertThat(((AppException) ex).getErrorCode()) .isEqualTo(BadgeErrorCode.USER_NOT_FOUND)); // 사용자 미존재 시 토큰 집계 조회는 전혀 일어나지 않아야 한다 - verifyNoInteractions(tokenRepo); + then(tokenRepo).shouldHaveNoInteractions(); } } @@ -146,14 +146,14 @@ class Query_HappyPath { ModelUsageRow opus = modelRow("opus", 700L); ModelUsageRow sonnet = modelRow("sonnet", 300L); CacheTokenSum cache = cacheRow(70L, 20L, 10L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(1_000L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of(daily)); - when(tokenRepo.findTop5Models(GITHUB_ID)) - .thenReturn(List.of(opus, sonnet)); - when(tokenRepo.sumCacheTokens(GITHUB_ID)) - .thenReturn(List.of(cache)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(1_000L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of(daily)); + given(tokenRepo.findTop5Models(GITHUB_ID)) + .willReturn(List.of(opus, sonnet)); + given(tokenRepo.sumCacheTokens(GITHUB_ID)) + .willReturn(List.of(cache)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -176,12 +176,12 @@ class Query_HappyPath { void 배지조회_상위모델없음_빈리스트반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -200,12 +200,12 @@ class Query_TotalTokens { void 누적토큰_sumTotalTokensNull_0반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(null); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(null); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -219,12 +219,12 @@ class Query_TotalTokens { void 누적토큰_정상값_그대로반환() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(987_654L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(987_654L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -249,13 +249,13 @@ class Query_Heatbar { // 중첩 stubbing 방지를 위해 mock row를 먼저 생성 DailyUsageRow midRow = dailyRow(midStr, 42L); DailyUsageRow todayRow = dailyRow(todayStr, 99L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); // 30일 중 2일치만 데이터가 존재 - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, from, today)) - .thenReturn(List.of(midRow, todayRow)); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, from, today)) + .willReturn(List.of(midRow, todayRow)); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -279,12 +279,12 @@ class Query_Heatbar { void 히트바_조회결과없음_전부0인30개구성() { // given LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); @@ -302,12 +302,12 @@ class Query_CacheHitRate { /** 캐시 히트율 검증을 위한 공통 stub: 사용자/누적/히트바/모델은 비우고 cacheRows만 주입. */ private void stubBaseWith(List cacheRows) { LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(cacheRows); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(cacheRows); } @Test @@ -375,9 +375,9 @@ class Query_NeedsGating { // given // 연간 계열(DAILY_1Y/STREAK_DAYS/YEAR_TOKENS)만 요구 → 일별 쿼리는 365일 창으로 1회 LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of()); // when service.query(USERNAME, BADGE_CODE, EnumSet.of( @@ -385,10 +385,10 @@ class Query_NeedsGating { // then // 일별 쿼리는 365일 창으로 1회 호출되고, 통계 3쿼리는 전혀 실행되지 않아야 한다 - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); - verify(tokenRepo, never()).findTop5Models(GITHUB_ID); - verify(tokenRepo, never()).sumCacheTokens(GITHUB_ID); - verify(tokenRepo, never()).sumTotalTokens(GITHUB_ID); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); + then(tokenRepo).should(never()).findTop5Models(GITHUB_ID); + then(tokenRepo).should(never()).sumCacheTokens(GITHUB_ID); + then(tokenRepo).should(never()).sumTotalTokens(GITHUB_ID); } @Test @@ -397,18 +397,18 @@ class Query_NeedsGating { // given // gpu 기본 4종 → 히트바 30일 창 유지, 연간 필드는 빈값 LocalDate today = today(); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.sumTotalTokens(GITHUB_ID)).thenReturn(0L); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) - .thenReturn(List.of()); - when(tokenRepo.findTop5Models(GITHUB_ID)).thenReturn(List.of()); - when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.sumTotalTokens(GITHUB_ID)).willReturn(0L); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today)) + .willReturn(List.of()); + given(tokenRepo.findTop5Models(GITHUB_ID)).willReturn(List.of()); + given(tokenRepo.sumCacheTokens(GITHUB_ID)).willReturn(List.of()); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today); assertThat(res.daily1y()).isEmpty(); assertThat(res.streakDays()).isZero(); assertThat(res.yearTokens()).isZero(); @@ -420,9 +420,9 @@ class Query_NeedsGating { // given LocalDate today = today(); DailyUsageRow todayRow = dailyRow(today.toString(), 500L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of(todayRow)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of(todayRow)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_1Y)); @@ -448,16 +448,16 @@ class Query_NeedsGating { String midStr = today.minusDays(15).toString(); DailyUsageRow midRow = dailyRow(midStr, 42L); DailyUsageRow todayRow = dailyRow(todayStr, 99L); - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(List.of(midRow, todayRow)); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(List.of(midRow, todayRow)); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_30D, BadgeDataNeed.DAILY_1Y)); // then - verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); + then(tokenRepo).should().findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today); assertThat(res.daily30d()).hasSize(30); assertThat(res.daily30d().get(29).date()).isEqualTo(todayStr); assertThat(res.daily30d().get(29).total()).isEqualTo(99L); @@ -472,9 +472,9 @@ class Query_Streak { /** streak 요구(YEAR 계열) 시나리오 공통 stub: 365일 창 일별 rows만 주입. */ private void stubYearRows(LocalDate today, List rows) { - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) - .thenReturn(rows); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(tokenRepo.findDailyTotalsBetween(GITHUB_ID, today.minusDays(364), today)) + .willReturn(rows); } @Test @@ -593,8 +593,8 @@ class Query_Snapshot { void 스냅샷_요구시_PK조회1회payload반환() { // given String payload = "{\"signupRank\":1}"; - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).thenReturn(payload); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).willReturn(payload); // when BadgeResponse res = service.query(USERNAME, "beta-tester", @@ -603,8 +603,8 @@ class Query_Snapshot { // then // 스냅샷만 요구하면 토큰 집계 쿼리는 전혀 실행되지 않는다 assertThat(res.snapshot()).isEqualTo(payload); - verify(snapshotRepo).findPayload(GITHUB_ID, "beta-tester"); - verifyNoInteractions(tokenRepo); + then(snapshotRepo).should().findPayload(GITHUB_ID, "beta-tester"); + then(tokenRepo).shouldHaveNoInteractions(); } @Test @@ -612,14 +612,14 @@ class Query_Snapshot { void 스냅샷_미요구_조회안함() { // given // BADGE_SNAPSHOT을 요구하지 않는 테마의 경로 (경계) - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); // when BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.noneOf(BadgeDataNeed.class)); // then assertThat(res.snapshot()).isEmpty(); - verifyNoInteractions(snapshotRepo); + then(snapshotRepo).shouldHaveNoInteractions(); } @Test @@ -627,8 +627,8 @@ class Query_Snapshot { void 스냅샷_행없음_빈문자열() { // given // 자격은 있는데 스냅샷 적재가 누락된 사용자 (경계) - when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); - when(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).thenReturn(null); + given(userRepo.findByUsername(USERNAME)).willReturn(Optional.of(user())); + given(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).willReturn(null); // when BadgeResponse res = service.query(USERNAME, "beta-tester", diff --git a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java index bd9d4d9..a65351e 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/service/BadgeRenderServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.util.ReflectionTestUtils; @@ -33,9 +34,9 @@ import static org.mockito.ArgumentMatchers.any; 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.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; /** * BadgeRenderService.getSvg()의 자격 확인, 캐시 키 정규화, 캐시 적중·미적중, 예외 전파를 검증한다. @@ -91,14 +92,14 @@ private BadgeResponse sampleData() { /** theme이 이미 정규화된 값(gpu)이고 mode가 LIGHT로 확정될 때의 정규화 스텁. */ private void stubNormalizeIdentity() { - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(svgBuilder.resolveMode(THEME, MODE)).thenReturn(BadgeMode.LIGHT); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(svgBuilder.resolveMode(THEME, MODE)).willReturn(BadgeMode.LIGHT); } /** 자격이 있는 정상 경로 스텁. 이 스텁이 없으면 Mockito 기본값(null)으로 NPE가 난다. */ private void stubGranted() { - when(badgeGrantService.resolveGrant(anyString(), anyString())) - .thenReturn(BadgeGrantResult.allow()); + given(badgeGrantService.resolveGrant(anyString(), anyString())) + .willReturn(BadgeGrantResult.allow()); } @Nested @@ -110,12 +111,12 @@ class GrantGate { void 배지조회_자격없음_잠금SVG반환하고조회캐시생략() { // given String lockedSvg = "locked"; - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(badgeGrantService.resolveGrant(USERNAME, THEME)) - .thenReturn(BadgeGrantResult.deny("Contributor", "PR을 보내주세요")); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.deny("Contributor", "PR을 보내주세요")); // 잠금 렌더는 locked 테마 자신의 지원 집합으로 모드를 다시 정규화한다 - when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); - when(lockedBadgeTheme.render("Contributor", "PR을 보내주세요", false)).thenReturn(lockedSvg); + given(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).willReturn(BadgeMode.LIGHT); + given(lockedBadgeTheme.render("Contributor", "PR을 보내주세요", false)).willReturn(lockedSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -124,27 +125,27 @@ class GrantGate { assertThat(result.svg()).isEqualTo(lockedSvg); assertThat(result.granted()).isFalse(); // 자격 거부 경로는 집계 조회·SVG 빌드·캐시 저장을 전부 건너뛴다 - verify(queryService, never()).query(anyString(), anyString(), any()); - verify(svgBuilder, never()).build(any(), anyString(), anyString()); - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(queryService).should(never()).query(anyString(), anyString(), any()); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } @Test @DisplayName("배지조회_자격없음_캐시를조회조차하지않는다") void 배지조회_자격없음_캐시를조회조차하지않는다() { // given - when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(badgeGrantService.resolveGrant(USERNAME, THEME)) - .thenReturn(BadgeGrantResult.deny(null, null)); - when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); - when(lockedBadgeTheme.render(null, null, false)).thenReturn("locked"); + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.deny(null, null)); + given(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).willReturn(BadgeMode.LIGHT); + given(lockedBadgeTheme.render(null, null, false)).willReturn("locked"); // when service.getSvg(USERNAME, THEME, MODE); // then // 게이트가 캐시보다 앞에 있어야 캐시 키와 내용 불일치가 생기지 않는다 - verify(redis, never()).opsForValue(); + then(redis).should(never()).opsForValue(); } @Test @@ -153,15 +154,37 @@ class GrantGate { // given stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn("cached"); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn("cached"); // when service.getSvg(USERNAME, THEME, MODE); // then // 판정을 캐시 뒤로 미루면 자격 회수가 최대 60분간 반영되지 않는다 - verify(badgeGrantService).resolveGrant(USERNAME, THEME); + then(badgeGrantService).should().resolveGrant(USERNAME, THEME); + } + + @Test + @DisplayName("배지조회_미가입_USER_NOT_FOUND예외") + void 배지조회_미가입_USER_NOT_FOUND예외() { + // given + given(svgBuilder.normalizeTheme(THEME)).willReturn(THEME); + given(badgeGrantService.resolveGrant(USERNAME, THEME)) + .willReturn(BadgeGrantResult.userNotFound()); + + // when + // then + assertThatThrownBy(() -> service.getSvg(USERNAME, THEME, MODE)) + .isInstanceOf(AppException.class) + .satisfies(ex -> { + assertThat(((AppException) ex).getErrorCode().getCode()).isEqualTo("BADGE_001"); + assertThat(((AppException) ex).getErrorCode().getStatus()).isEqualTo(HttpStatus.NOT_FOUND); + }); + // 미가입은 잠금 SVG 대상이 아니다. 캐시·집계 조회에도 닿지 않는다. + then(lockedBadgeTheme).shouldHaveNoInteractions(); + then(queryService).shouldHaveNoInteractions(); + then(redis).should(never()).opsForValue(); } } @@ -176,8 +199,8 @@ class CacheHit { String cachedSvg = "cached"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(cachedSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(cachedSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -185,10 +208,10 @@ class CacheHit { // then assertThat(result.svg()).isEqualTo(cachedSvg); assertThat(result.granted()).isTrue(); - verify(queryService, never()).query(anyString(), anyString(), any()); - verify(svgBuilder, never()).build(any(), anyString(), anyString()); + then(queryService).should(never()).query(anyString(), anyString(), any()); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); // 캐시 히트 시 set은 호출되지 않아야 한다 - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } } @@ -204,11 +227,11 @@ class CacheMiss { String builtSvg = "built"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, THEME, MODE)).willReturn(builtSvg); // when BadgeSvgResponse result = service.getSvg(USERNAME, THEME, MODE); @@ -216,8 +239,8 @@ class CacheMiss { // then assertThat(result.svg()).isEqualTo(builtSvg); assertThat(result.granted()).isTrue(); - verify(queryService).query(USERNAME, THEME, GPU_NEEDS); - verify(svgBuilder).build(data, THEME, MODE); + then(queryService).should().query(USERNAME, THEME, GPU_NEEDS); + then(svgBuilder).should().build(data, THEME, MODE); } @Test @@ -228,11 +251,11 @@ class CacheMiss { String builtSvg = "built"; stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, THEME, MODE)).willReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); @@ -241,7 +264,7 @@ class CacheMiss { service.getSvg(USERNAME, THEME, MODE); // then - verify(valueOps).set(keyCaptor.capture(), eq(builtSvg), ttlCaptor.capture()); + then(valueOps).should().set(keyCaptor.capture(), eq(builtSvg), ttlCaptor.capture()); assertThat(keyCaptor.getValue()).isEqualTo(EXPECTED_CACHE_KEY); assertThat(ttlCaptor.getValue()).isEqualTo(Duration.ofMinutes(CACHE_TTL_MINUTES)); } @@ -252,15 +275,15 @@ class CacheMiss { // given: 임의 theme/mode가 svgBuilder 정규화로 gpu/light로 수렴 BadgeResponse data = sampleData(); String builtSvg = "built"; - when(svgBuilder.normalizeTheme("ZZZ")).thenReturn("gpu"); - when(svgBuilder.resolveMode("gpu", "GARBAGE")).thenReturn(BadgeMode.LIGHT); + given(svgBuilder.normalizeTheme("ZZZ")).willReturn("gpu"); + given(svgBuilder.resolveMode("gpu", "GARBAGE")).willReturn(BadgeMode.LIGHT); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); // needsOf는 정규화된 theme("gpu")로 호출된다 - when(svgBuilder.needsOf("gpu")).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); - when(svgBuilder.build(data, "gpu", "light")).thenReturn(builtSvg); + given(svgBuilder.needsOf("gpu")).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)).willReturn(data); + given(svgBuilder.build(data, "gpu", "light")).willReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); @@ -269,11 +292,11 @@ class CacheMiss { // then: raw 입력과 무관하게 정규화된 단일 키로 수렴 (캐시 증식 방지) assertThat(result.svg()).isEqualTo(builtSvg); - verify(valueOps).set(keyCaptor.capture(), eq(builtSvg), any(Duration.class)); + then(valueOps).should().set(keyCaptor.capture(), eq(builtSvg), any(Duration.class)); assertThat(keyCaptor.getValue()).isEqualTo(EXPECTED_CACHE_KEY); - verify(svgBuilder).build(data, "gpu", "light"); + then(svgBuilder).should().build(data, "gpu", "light"); // 자격 판정도 정규화된 theme으로 이뤄져야 한다 - verify(badgeGrantService).resolveGrant(USERNAME, "gpu"); + then(badgeGrantService).should().resolveGrant(USERNAME, "gpu"); } } @@ -287,11 +310,11 @@ class Failure { // given stubNormalizeIdentity(); stubGranted(); - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); - when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, THEME, GPU_NEEDS)) - .thenThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.get(EXPECTED_CACHE_KEY)).willReturn(null); + given(svgBuilder.needsOf(THEME)).willReturn(GPU_NEEDS); + given(queryService.query(USERNAME, THEME, GPU_NEEDS)) + .willThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); // when & then assertThatThrownBy(() -> service.getSvg(USERNAME, THEME, MODE)) @@ -299,8 +322,8 @@ class Failure { .hasMessage(BadgeErrorCode.USER_NOT_FOUND.getMessage()); // 예외 발생 시 SVG 생성과 캐시 저장은 일어나지 않아야 한다 - verify(svgBuilder, never()).build(any(), anyString(), anyString()); - verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); + then(svgBuilder).should(never()).build(any(), anyString(), anyString()); + then(valueOps).should(never()).set(anyString(), anyString(), any(Duration.class)); } } } 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/reset/service/ResetOrchestratorTest.java b/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java index 7973d1f..1e2a444 100644 --- a/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java +++ b/src/test/java/com/tokenphage/api/feature/reset/service/ResetOrchestratorTest.java @@ -24,12 +24,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; /** * ResetOrchestrator.reset(Jwt) 검증: 쿨다운 게이트 → DB 삭제 → 배지 캐시 무효화 흐름과 실패 격리 동작. @@ -65,8 +64,8 @@ void setUp() { orchestrator = new ResetOrchestrator(resetService, badgeCacheInvalidator, redis); jwt = mock(Jwt.class); - when(jwt.getSubject()).thenReturn(GITHUB_ID); - when(jwt.getClaimAsString("username")).thenReturn(USERNAME); + given(jwt.getSubject()).willReturn(GITHUB_ID); + given(jwt.getClaimAsString("username")).willReturn(USERNAME); } @Nested @@ -77,25 +76,25 @@ class ResetSuccess { @DisplayName("전체초기화_쿨다운선점성공_삭제와캐시무효화수행") void 전체초기화_쿨다운선점성공_삭제와캐시무효화수행() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); // when orchestrator.reset(jwt); // then - verify(resetService).resetUsage(-12345L); - verify(badgeCacheInvalidator).evict(USERNAME); + then(resetService).should().resetUsage(-12345L); + then(badgeCacheInvalidator).should().evict(USERNAME); // 정상 흐름에서는 쿨다운 해제(delete)를 호출하지 않는다. - verify(redis, never()).delete(anyString()); + then(redis).should(never()).delete(anyString()); } @Test @DisplayName("전체초기화_쿨다운키검증_githubId기반키사용") void 전체초기화_쿨다운키검증_githubId기반키사용() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(keyCaptor.capture(), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(keyCaptor.capture(), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); // when orchestrator.reset(jwt); @@ -113,8 +112,8 @@ class ResetCooldown { @DisplayName("전체초기화_쿨다운미선점_RESET_COOLDOWN발생") void 전체초기화_쿨다운미선점_RESET_COOLDOWN발생() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.FALSE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.FALSE); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) @@ -123,8 +122,8 @@ class ResetCooldown { .isEqualTo(ResetErrorCode.RESET_COOLDOWN); // 쿨다운으로 거부되면 DB 삭제와 캐시 무효화는 일어나지 않는다. - verifyNoInteractions(resetService); - verifyNoInteractions(badgeCacheInvalidator); + then(resetService).shouldHaveNoInteractions(); + then(badgeCacheInvalidator).shouldHaveNoInteractions(); } @Test @@ -132,14 +131,14 @@ class ResetCooldown { void 전체초기화_setIfAbsent반환null_쿨다운거부() { // given // setIfAbsent가 null을 반환해도 Boolean.TRUE.equals(null)==false 이므로 거부되어야 한다(경계 케이스). - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(null); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(null); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) .isInstanceOf(AppException.class); - verify(resetService, never()).resetUsage(any()); + then(resetService).should(never()).resetUsage(any()); } } @@ -151,18 +150,18 @@ class ResetDbFailure { @DisplayName("전체초기화_DB삭제실패_쿨다운해제와예외전파") void 전체초기화_DB삭제실패_쿨다운해제와예외전파() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); RuntimeException dbError = new RuntimeException("DB write failed"); - doThrow(dbError).when(resetService).resetUsage(-12345L); + willThrow(dbError).given(resetService).resetUsage(-12345L); // when & then assertThatThrownBy(() -> orchestrator.reset(jwt)) .isSameAs(dbError); // 서버 오류로 24시간 잠기지 않도록 쿨다운 키를 해제하고, 캐시 무효화는 수행하지 않는다. - verify(redis).delete(COOLDOWN_KEY); - verifyNoInteractions(badgeCacheInvalidator); + then(redis).should().delete(COOLDOWN_KEY); + then(badgeCacheInvalidator).shouldHaveNoInteractions(); } } @@ -174,17 +173,17 @@ class ResetCacheEvictionFailure { @DisplayName("전체초기화_캐시무효화실패_예외없이완료") void 전체초기화_캐시무효화실패_예외없이완료() { // given - when(redis.opsForValue()).thenReturn(valueOps); - when(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).thenReturn(Boolean.TRUE); - doThrow(new RuntimeException("cache eviction failed")).when(badgeCacheInvalidator).evict(USERNAME); + given(redis.opsForValue()).willReturn(valueOps); + given(valueOps.setIfAbsent(eq(COOLDOWN_KEY), anyString(), any(Duration.class))).willReturn(Boolean.TRUE); + willThrow(new RuntimeException("cache eviction failed")).given(badgeCacheInvalidator).evict(USERNAME); // when & then // DB 삭제는 이미 커밋됐고 캐시 무효화는 best-effort이므로 reset 자체는 성공해야 한다. assertThatCode(() -> orchestrator.reset(jwt)).doesNotThrowAnyException(); // 삭제는 정상 수행, 캐시 실패로 쿨다운을 해제하지 않는다(delete 미호출). - verify(resetService).resetUsage(-12345L); - verify(redis, never()).delete(anyString()); + then(resetService).should().resetUsage(-12345L); + then(redis).should(never()).delete(anyString()); } } } diff --git a/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java b/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java index 0f1eed7..bccac0f 100644 --- a/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java +++ b/src/test/java/com/tokenphage/api/feature/reset/service/ResetServiceTest.java @@ -2,8 +2,8 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository; import org.junit.jupiter.api.DisplayName; @@ -35,38 +35,38 @@ class ResetUsage { @DisplayName("정상 초기화: 사용자의 토큰 사용량을 삭제한다") void 초기화_정상호출_토큰삭제() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)).thenReturn(3); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)).willReturn(3); // when resetService.resetUsage(GITHUB_ID); // then - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } @Test @DisplayName("경계값: 삭제된 행이 0이어도 예외 없이 정상 동작한다") void 초기화_삭제행0_예외없음() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)).thenReturn(0); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)).willReturn(0); // when / then assertThatCode(() -> resetService.resetUsage(GITHUB_ID)).doesNotThrowAnyException(); - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } @Test @DisplayName("실패 전파: 토큰 삭제가 예외를 던지면 그대로 전파된다") void 초기화_삭제예외_전파() { // given - when(tokenRepo.deleteAllByGithubId(GITHUB_ID)) - .thenThrow(new RuntimeException("DB delete failed")); + given(tokenRepo.deleteAllByGithubId(GITHUB_ID)) + .willThrow(new RuntimeException("DB delete failed")); // when / then assertThatThrownBy(() -> resetService.resetUsage(GITHUB_ID)) .isInstanceOf(RuntimeException.class) .hasMessageContaining("DB delete failed"); - verify(tokenRepo).deleteAllByGithubId(GITHUB_ID); + then(tokenRepo).should().deleteAllByGithubId(GITHUB_ID); } } } 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(); + } } } 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(); } } }