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
+ * 이 예외들은 모두 {@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
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/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java b/src/test/java/com/tokenphage/api/domain/badge/service/BadgeGrantServiceTest.java
index 18932a6..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
@@ -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
+ * 실제 디스패치를 태워 핸들러 도달 경로를 확인한다. 컨트롤러는 라우팅을 태우기 위한 발판이다.
+ */
+@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/badge/controller/BadgeControllerTest.java b/src/test/java/com/tokenphage/api/feature/badge/controller/BadgeControllerTest.java
index 94560c9..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
@@ -9,11 +9,14 @@
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 {
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 12582fc..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;
@@ -163,6 +164,28 @@ class GrantGate {
// 판정을 캐시 뒤로 미루면 자격 회수가 최대 60분간 반영되지 않는다
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();
+ }
}
@Nested
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();
}
}
}