From 4ac83c4ca3f612e63143b7482a4b82ca834f602b Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 26 Aug 2026 00:37:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20beta-tester=20=EB=B0=B0=EC=A7=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=99=80=20mode=20=EC=B6=95=20enum=20?= =?UTF-8?q?=EA=B0=9C=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클로즈드 베타 기여자용 터미널 창 배지를 추가한다. 표시 값이 마감된 구간의 집계라 렌더마다 변하면 안 되므로, badge_snapshot 테이블에 미리 계산한 JSON을 복합 PK로 저장하고 렌더는 그것만 읽는다. V7이 스냅샷·자격·이력을 함께 적재한다. 악센트 3색(cyan/green/purple)을 ?mode= 축에 노출하기 위해 boolean isDark를 BadgeMode enum으로 바꿨다. 기존 배지 3종은 패밀리 훅에서 boolean을 유지해 렌더 결과가 바뀌지 않는다. --- .../api/domain/badge/BadgeCode.java | 3 + .../repository/BadgeSnapshotRepository.java | 28 + .../repository/entity/BadgeSnapshot.java | 38 ++ .../repository/entity/BadgeSnapshotId.java | 46 ++ .../badge/controller/BadgeController.java | 3 +- .../badge/dto/response/BadgeResponse.java | 5 +- .../badge/service/BadgeQueryService.java | 27 +- .../badge/service/BadgeRenderService.java | 19 +- .../api/feature/badge/svg/BadgeDataNeed.java | 5 +- .../api/feature/badge/svg/BadgeMode.java | 57 ++ .../api/feature/badge/svg/BadgeTheme.java | 27 +- .../api/feature/badge/svg/SvgBuilder.java | 41 +- .../betatester/BetaTesterBadgeTheme.java | 264 +++++++++ .../theme/betatester/BetaTesterColors.java | 44 ++ .../theme/betatester/BetaTesterMascot.java | 51 ++ .../betatester/BetaTesterProviderMark.java | 95 ++++ .../theme/betatester/BetaTesterSnapshot.java | 87 +++ .../badge/svg/theme/card/CardBadgeTheme.java | 30 +- .../svg/theme/grass/GrassBadgeTheme.java | 36 +- .../svg/theme/locked/LockedBadgeTheme.java | 32 +- .../migration/V6__create_badge_snapshot.sql | 27 + .../migration/V7__add_beta_tester_badge.sql | 61 ++ .../badge/BadgeCatalogConsistencyTest.java | 69 ++- .../api/domain/badge/BadgeCodeTest.java | 8 +- .../entity/BadgeSnapshotIdTest.java | 177 ++++++ .../badge/controller/BadgeControllerTest.java | 18 + .../badge/service/BadgeQueryServiceTest.java | 103 +++- .../badge/service/BadgeRenderServiceTest.java | 33 +- .../api/feature/badge/svg/BadgeModeTest.java | 130 +++++ .../api/feature/badge/svg/SvgBuilderTest.java | 40 +- .../betatester/BetaTesterBadgeThemeTest.java | 522 ++++++++++++++++++ .../betatester/BetaTesterMascotTest.java | 194 +++++++ .../BetaTesterProviderMarkTest.java | 153 +++++ .../betatester/BetaTesterSnapshotTest.java | 176 ++++++ .../card/claude/CardClaudeBadgeThemeTest.java | 25 +- .../theme/card/gpu/CardGpuBadgeThemeTest.java | 89 +-- .../claude/GrassClaudeBadgeThemeTest.java | 35 +- .../theme/locked/LockedBadgeThemeTest.java | 5 +- ...adgeSnapshotRepositoryIntegrationTest.java | 188 +++++++ 39 files changed, 2789 insertions(+), 202 deletions(-) create mode 100644 src/main/java/com/tokenphage/api/domain/badge/repository/BadgeSnapshotRepository.java create mode 100644 src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java create mode 100644 src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/BadgeMode.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterColors.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascot.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterProviderMark.java create mode 100644 src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterSnapshot.java create mode 100644 src/main/resources/db/migration/V6__create_badge_snapshot.sql create mode 100644 src/main/resources/db/migration/V7__add_beta_tester_badge.sql create mode 100644 src/test/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotIdTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/badge/svg/BadgeModeTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeThemeTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascotTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterProviderMarkTest.java create mode 100644 src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterSnapshotTest.java create mode 100644 src/test/java/com/tokenphage/api/integration/BadgeSnapshotRepositoryIntegrationTest.java diff --git a/src/main/java/com/tokenphage/api/domain/badge/BadgeCode.java b/src/main/java/com/tokenphage/api/domain/badge/BadgeCode.java index 8a8e7e5..f7a06ca 100644 --- a/src/main/java/com/tokenphage/api/domain/badge/BadgeCode.java +++ b/src/main/java/com/tokenphage/api/domain/badge/BadgeCode.java @@ -18,6 +18,9 @@ public enum BadgeCode { CLAUDE("claude"), GRASS_CLAUDE("grass-claude"), + /** 클로즈드 베타(2026-07~08) 기여자에게 운영자가 부여하는 배지. 표시 값은 고정 스냅샷에서 온다. */ + BETA_TESTER("beta-tester"), + /** 자격이 없어 사용할 수 없음을 알리는 안내 배지. 사용자가 직접 고르는 테마는 아니다. */ LOCKED("locked"); diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/BadgeSnapshotRepository.java b/src/main/java/com/tokenphage/api/domain/badge/repository/BadgeSnapshotRepository.java new file mode 100644 index 0000000..872fb63 --- /dev/null +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/BadgeSnapshotRepository.java @@ -0,0 +1,28 @@ +package com.tokenphage.api.domain.badge.repository; + +import com.tokenphage.api.domain.badge.repository.entity.BadgeSnapshot; +import com.tokenphage.api.domain.badge.repository.entity.BadgeSnapshotId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface BadgeSnapshotRepository extends JpaRepository { + + /** + * 스냅샷 payload JSON 원문을 복합 PK로 조회한다. + *

+ * jsonb는 ::text로 꺼내야 드라이버가 String으로 반환한다. + * + * @param githubId 배지 주인 GitHub 숫자 ID (null 불허) + * @param badgeCode 배지 코드 (null 불허) + * @return payload JSON 원문, 행이 없으면 null + * @Since 2026-08-23 + */ + @Query(value = """ + SELECT bs.payload::text + FROM badge_snapshot bs + WHERE bs.github_id = :githubId + AND bs.badge_code = :badgeCode + """, nativeQuery = true) + String findPayload(@Param("githubId") Long githubId, @Param("badgeCode") String badgeCode); +} diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java new file mode 100644 index 0000000..325082e --- /dev/null +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshot.java @@ -0,0 +1,38 @@ +package com.tokenphage.api.domain.badge.repository.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.time.Instant; + +/** + * 값이 고정된 배지의 미리 계산된 표시 데이터(스냅샷) 엔티티. + *

+ * payload는 String으로 매핑한다 — 엔티티가 JSON 구조를 몰라야 여러 배지가 공유한다. + * 스키마 해석은 각 배지 패키지가 한다. 적재는 마이그레이션이 하므로 setter가 없다. + */ +@Entity +@Table(name = "badge_snapshot") +@IdClass(BadgeSnapshotId.class) +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BadgeSnapshot { + + @Id + @Column(name = "github_id") + private Long githubId; + + @Id + @Column(name = "badge_code", length = 40) + private String badgeCode; + + /** 표시할 값 전부를 담은 JSON 원문. */ + @Column(name = "payload", columnDefinition = "jsonb", nullable = false) + private String payload; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); +} diff --git a/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java new file mode 100644 index 0000000..a05fda0 --- /dev/null +++ b/src/main/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotId.java @@ -0,0 +1,46 @@ +package com.tokenphage.api.domain.badge.repository.entity; + +import java.io.Serializable; +import java.util.Objects; + +/** + * badge_snapshot 복합키(@IdClass) 클래스. + *

+ * 값 생성자는 findById 호출용이다. + */ +public class BadgeSnapshotId implements Serializable { + private Long githubId; + private String badgeCode; + + public BadgeSnapshotId() { + } + + /** + * 조회에 쓸 복합키를 만든다. + * + * @param githubId GitHub 숫자 ID + * @param badgeCode 배지 코드 (badge_catalog.code) + * @Since 2026-08-23 + */ + public BadgeSnapshotId(Long githubId, String badgeCode) { + this.githubId = githubId; + this.badgeCode = badgeCode; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BadgeSnapshotId that)) { + return false; + } + return Objects.equals(githubId, that.githubId) && + Objects.equals(badgeCode, that.badgeCode); + } + + @Override + public int hashCode() { + return Objects.hash(githubId, badgeCode); + } +} 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 3c3ac0c..afad0a7 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 @@ -24,7 +24,8 @@ public class BadgeController { * @param request 결과 값을 실어 보낼 현재 요청 * @param username 배지를 조회할 GitHub 사용자명 * @param theme 배지 스킨 종류 (예: "gpu", 기본값: "gpu") - * @param mode 색상 모드 ("light" 또는 "dark", 기본값: "light") + * @param mode 색상 모드 — 지원 값은 테마별로 다르다 (카드·잔디 "light"/"dark", + * beta-tester "cyan"/"green"/"purple"). 미지원 값은 테마 기본 모드로 정규화 (기본값: "light") * @return SVG 형식의 배지 이미지 * @Since 2026-05-27 */ diff --git a/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java b/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java index 2a6b457..e70b7c2 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java +++ b/src/main/java/com/tokenphage/api/feature/badge/dto/response/BadgeResponse.java @@ -18,6 +18,7 @@ * @param yearTokens 최근 1년 총 토큰 — {@code YEAR_TOKENS} * @param streakDays 연속 사용일 — {@code STREAK_DAYS} * @param daily1y 최근 1년 일별 사용량 — {@code DAILY_1Y} + * @param snapshot 고정 스냅샷 payload JSON 원문 — {@code BADGE_SNAPSHOT} (없으면 빈 문자열, 해석은 배지 패키지 소유) */ public record BadgeResponse( // ── 공통 ── @@ -30,5 +31,7 @@ public record BadgeResponse( // ── 1년 활동 데이터 (grass 계열) ── long yearTokens, int streakDays, - List daily1y + List daily1y, + // ── 고정 스냅샷 (beta-tester 등 값이 변하지 않는 배지) ── + String snapshot ) {} diff --git a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java index b459785..f567b19 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java +++ b/src/main/java/com/tokenphage/api/feature/badge/service/BadgeQueryService.java @@ -1,5 +1,6 @@ package com.tokenphage.api.feature.badge.service; +import com.tokenphage.api.domain.badge.repository.BadgeSnapshotRepository; import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository; import com.tokenphage.api.domain.token.repository.projection.CacheTokenSum; import com.tokenphage.api.domain.user.repository.UserRepository; @@ -30,6 +31,7 @@ class BadgeQueryService { private final UserRepository userRepo; private final DailyTokenUsageRepository tokenRepo; + private final BadgeSnapshotRepository snapshotRepo; @Value("${badge.timezone}") private String timezone; @@ -41,14 +43,15 @@ class BadgeQueryService { * 일별 쿼리는 연간 계열(DAILY_1Y/STREAK_DAYS/YEAR_TOKENS) 또는 DAILY_30D 요구 시에만 * 최대 범위(연간 요구 시 365일, 아니면 30일)로 1회 조회한 뒤 히트바·연간일별·연간총량·streak을 파생한다. * - * @param username 배지를 조회할 GitHub 사용자명 (null 불허) - * @param needs 테마가 요구하는 데이터 종류 집합 (null 불허) + * @param username 배지를 조회할 GitHub 사용자명 (null 불허) + * @param badgeCode 정규화된 배지 코드 (null 불허) — 스냅샷 복합키의 절반 + * @param needs 테마가 요구하는 데이터 종류 집합 (null 불허) * @return 배지 렌더링에 필요한 집계 데이터 (미요구 필드는 빈값) * @throws AppException 사용자가 존재하지 않을 경우 (BADGE_001) * @Since 2026-07-15 */ - BadgeResponse query(String username, Set needs) { - log.debug("Querying badge data: username={}, needs={}", username, needs); + BadgeResponse query(String username, String badgeCode, Set needs) { + log.debug("Querying badge data: username={}, badgeCode={}, needs={}", username, badgeCode, needs); // #1. 사용자 조회 User user = userRepo.findByUsername(username) @@ -112,7 +115,18 @@ BadgeResponse query(String username, Set needs) { cacheHitRate = calcCacheHitRate(cacheRows.isEmpty() ? null : cacheRows.getFirst()); } - // #11. 조회된 데이터를 BadgeResponse로 조립하여 반환 + // #11. 고정 스냅샷: 요구 시 복합 PK로 1회 조회. 행이 없으면 빈 문자열 (적재 누락 신호) + String snapshot = ""; + if (needs.contains(BadgeDataNeed.BADGE_SNAPSHOT)) { + String payload = snapshotRepo.findPayload(githubId, badgeCode); + if (payload == null) { + log.warn("Badge snapshot missing: username={}, badgeCode={}", username, badgeCode); + } else { + snapshot = payload; + } + } + + // #12. 조회된 데이터를 BadgeResponse로 조립하여 반환 log.debug("Badge data ready: username={}, totalTokens={}, heatbarDays={}, yearDays={}, streakDays={}", username, totalTokens, heat30bar.size(), daily1y.size(), streakDays); return new BadgeResponse( @@ -123,7 +137,8 @@ BadgeResponse query(String username, Set needs) { cacheHitRate, yearTokens, streakDays, - daily1y + daily1y, + snapshot ); } 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 1fef2c4..20faa83 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 @@ -1,9 +1,11 @@ package com.tokenphage.api.feature.badge.service; +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.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse; +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; import lombok.RequiredArgsConstructor; @@ -42,7 +44,7 @@ public class BadgeRenderService { * * @param username 배지를 조회할 GitHub 사용자명 (null 불허) * @param theme 배지 스킨 종류 (미등록 값은 기본 테마로 정규화) - * @param mode 색상 모드 ("dark"가 아니면 "light"로 정규화) + * @param mode 색상 모드 (테마가 지원하지 않는 값은 그 테마의 기본 모드로 정규화) * @return SVG 문자열과 자격 판정 결과 * @throws com.tokenphage.api.exception.AppException 사용자가 없을 경우 (BADGE_001) * @Since 2026-05-27 @@ -50,19 +52,22 @@ public class BadgeRenderService { public BadgeSvgResponse getSvg(String username, String theme, String mode) { String normalizedTheme = svgBuilder.normalizeTheme(theme); - String normalizedMode = svgBuilder.normalizeMode(mode); // #0. 자격 확인은 캐시 조회보다 먼저. 캐시-자격 불일치를 막고 회수가 즉시 반영된다. // 잠금 SVG는 캐시하지 않으므로 부여도 즉시 반영된다. BadgeGrantResult grant = badgeGrantService.resolveGrant(username, normalizedTheme); if (!grant.granted()) { log.info("Badge locked, rendering lock notice: username={}, theme={}", username, normalizedTheme); + // 잠금 테마 자신의 지원 집합으로 다시 정규화한다 — 원 테마의 악센트 모드를 잠금 테마는 모른다. + BadgeMode lockedMode = svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), mode); String lockedSvg = lockedBadgeTheme.render( - grant.title(), grant.message(), svgBuilder.isDark(normalizedMode)); + grant.title(), grant.message(), lockedMode == BadgeMode.DARK); return new BadgeSvgResponse(lockedSvg, false); } - String cacheKey = "badge:" + username + ":" + normalizedTheme + ":" + normalizedMode; + // 캐시 키와 렌더가 같은 정규화 결과를 쓰도록 테마 기준으로 모드를 확정한다. + BadgeMode badgeMode = svgBuilder.resolveMode(normalizedTheme, mode); + String cacheKey = "badge:" + username + ":" + normalizedTheme + ":" + badgeMode.getCode(); String cached = redis.opsForValue().get(cacheKey); // #1. 배지(SVG)가 캐싱되어 있으면 즉시 리턴 @@ -71,13 +76,13 @@ public BadgeSvgResponse getSvg(String username, String theme, String mode) { return new BadgeSvgResponse(cached, true); } - log.info("Badge cache miss, building SVG: username={}, theme={}, mode={}", username, normalizedTheme, normalizedMode); + log.info("Badge cache miss, building SVG: username={}, theme={}, mode={}", username, normalizedTheme, badgeMode.getCode()); // #2. BadgeQueryService.query() : 정규화된 테마가 선언한 needs에 해당하는 사용자 토큰 정보만 조회 - BadgeResponse data = queryService.query(username, svgBuilder.needsOf(normalizedTheme)); + BadgeResponse data = queryService.query(username, normalizedTheme, svgBuilder.needsOf(normalizedTheme)); // #3. 사용자 토큰 정보 + 정규화된 theme/mode로 배지(SVG) 생성 - String svg = svgBuilder.build(data, normalizedTheme, normalizedMode); + String svg = svgBuilder.build(data, normalizedTheme, badgeMode.getCode()); // #4. 생성된 배지(SVG)를 Redis에 캐싱 redis.opsForValue().set(cacheKey, svg, Duration.ofMinutes(cacheTtlMinutes)); diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeDataNeed.java b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeDataNeed.java index 25834df..3ddb1ba 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeDataNeed.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeDataNeed.java @@ -31,7 +31,10 @@ public enum BadgeDataNeed { STREAK_DAYS(365), /** 최근 1년 총 토큰 — 1년 일별 데이터에서 파생 → BadgeResponse.yearTokens */ - YEAR_TOKENS(365); + YEAR_TOKENS(365), + + /** 값이 고정된 배지의 미리 계산된 스냅샷 JSON → BadgeResponse.snapshot */ + BADGE_SNAPSHOT(0); /** 이 데이터를 채우는 데 필요한 일별 조회 창(일). 0이면 일별 조회가 필요 없다. */ private final int dailyWindowDays; diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeMode.java b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeMode.java new file mode 100644 index 0000000..050fbe5 --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeMode.java @@ -0,0 +1,57 @@ +package com.tokenphage.api.feature.badge.svg; + +import java.util.Set; + +/** + * 배지 색상 모드 (URL {@code ?mode=} 축). + *

+ * 지원 집합과 기본값은 테마마다 다르다({@link BadgeTheme#supportedModes()}). + * 코드 문자열은 URL·Redis 캐시 키로 사용되며, 바꾸면 기존 배지 주소가 깨진다. + */ +public enum BadgeMode { + + LIGHT("light"), + DARK("dark"), + CYAN("cyan"), + GREEN("green"), + PURPLE("purple"); + + private final String code; + + BadgeMode(String code) { + this.code = code; + } + + /** + * URL 및 캐시 키에 쓰이는 소문자 코드 문자열을 반환한다. + * + * @return 소문자 코드 (예: "cyan") + * @Since 2026-08-23 + */ + public String getCode() { + return code; + } + + /** + * 원본 mode 문자열을 지원 집합 안의 모드로 정규화한다. + *

+ * 대소문자 무관 매칭. null·공백·미지원 값은 {@code fallback}으로 접는다. + * + * @param raw 원본 mode 파라미터 (null 허용) + * @param supported 테마가 지원하는 모드 집합 (null 불허) + * @param fallback 미지원 값일 때 반환할 기본 모드 (null 불허) + * @return 지원 집합 안의 모드 또는 fallback + * @Since 2026-08-23 + */ + public static BadgeMode from(String raw, Set supported, BadgeMode fallback) { + if (raw == null || raw.isBlank()) { + return fallback; + } + for (BadgeMode mode : supported) { + if (mode.code.equalsIgnoreCase(raw)) { + return mode; + } + } + return fallback; + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeTheme.java index 9d22ca6..a09f387 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeTheme.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/BadgeTheme.java @@ -15,14 +15,33 @@ public interface BadgeTheme { String name(); /** - * 배지 데이터와 모드로 SVG 문자열을 생성한다. + * 배지 데이터와 색상 모드로 SVG 문자열을 생성한다. * - * @param data 배지에 표시할 사용자 데이터 (null 불허) - * @param isDark true면 다크 모드, false면 라이트 모드 + * @param data 배지에 표시할 사용자 데이터 (null 불허) + * @param mode 색상 모드 (이 테마의 지원 집합 안 값, null 불허) * @return 완성된 SVG 마크업 문자열 * @Since 2026-05-27 */ - String build(BadgeResponse data, boolean isDark); + String build(BadgeResponse data, BadgeMode mode); + + /** + * 이 테마가 지원하는 색상 모드 집합을 선언한다. + *

+ * 지원하지 않는 mode 값은 디스패처가 {@link #defaultMode()}로 접는다. + * 기본 구현을 두지 않는다(정책) — needs()와 같다. + * + * @return 지원 모드 집합 + * @Since 2026-08-23 + */ + Set supportedModes(); + + /** + * 미지원 mode 값이 들어왔을 때 쓸 기본 모드를 반환한다. + * + * @return 기본 모드 + * @Since 2026-08-23 + */ + BadgeMode defaultMode(); /** * 이 테마가 렌더링에 필요로 하는 데이터 종류를 선언한다. diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/SvgBuilder.java b/src/main/java/com/tokenphage/api/feature/badge/svg/SvgBuilder.java index fe12f48..fcf7664 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/SvgBuilder.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/SvgBuilder.java @@ -16,8 +16,6 @@ public class SvgBuilder { private static final String DEFAULT_THEME = BadgeCode.GPU.getCode(); - private static final String DARK_MODE = "dark"; - private static final String LIGHT_MODE = "light"; private final Map themes; @@ -28,19 +26,19 @@ public SvgBuilder(List themeList) { /** * theme과 mode에 맞는 BadgeTheme을 선택하여 SVG를 생성한다. - * 등록되지 않은 theme은 기본 테마("gpu")로, "dark"가 아닌 mode는 "light"로 정규화한다. + * 등록되지 않은 theme은 기본 테마("gpu")로, mode는 {@link #resolveMode}로 정규화한다. * * @param data 배지에 표시할 사용자 데이터 (null 불허) * @param theme 뱃지 스킨 종류 (예: "gpu") - * @param mode 색상 모드 ("dark" 또는 "light") + * @param mode 색상 모드 (테마별 지원 값, null 허용) * @return 완성된 SVG 마크업 문자열 * @Since 2026-05-27 */ public String build(BadgeResponse data, String theme, String mode) { BadgeTheme selected = themes.get(normalizeTheme(theme)); - boolean isDark = DARK_MODE.equals(normalizeMode(mode)); + BadgeMode resolved = resolveMode(theme, mode); log.debug("Building SVG badge: user={}, theme={}, mode={}", data.username(), theme, mode); - return selected.build(data, isDark); + return selected.build(data, resolved); } /** @@ -77,29 +75,18 @@ public String normalizeTheme(String theme) { } /** - * mode 문자열을 "dark" 또는 "light"로 정규화한다. + * mode 문자열을 해당 테마가 지원하는 모드로 정규화한다. *

- * "dark"(대소문자 무관)면 "dark"를, 그 외 모든 값(null 포함)은 "light"를 반환한다. + * 테마별로 정규화한다 — 테마가 지원하지 않는 값(null·공백 포함)은 그 테마의 + * {@link BadgeTheme#defaultMode()}로 접는다. 캐시 키 카디널리티가 테마별 지원 집합으로 묶인다. * - * @param mode 원본 mode 파라미터 (null 허용) - * @return "dark" 또는 "light" - * @Since 2026-06-11 - */ - public String normalizeMode(String mode) { - return DARK_MODE.equalsIgnoreCase(mode) ? DARK_MODE : LIGHT_MODE; - } - - /** - * mode 문자열이 다크 모드인지 판정한다. - *

- * BadgeTheme.build()는 boolean isDark를 받으므로, 디스패처를 거치지 않고 테마를 직접 - * 호출하는 쪽(잠금 안내 렌더)이 같은 기준으로 변환할 수 있게 노출한다. - * - * @param mode 원본 또는 정규화된 mode 문자열 (null 허용) - * @return "dark"면 true, 그 외에는 false - * @Since 2026-08-10 + * @param theme 원본 theme 파라미터 (null 허용, 미등록이면 기본 테마 기준) + * @param mode 원본 mode 파라미터 (null 허용) + * @return 테마 지원 집합 내부 모드 + * @Since 2026-08-23 */ - public boolean isDark(String mode) { - return DARK_MODE.equalsIgnoreCase(mode); + public BadgeMode resolveMode(String theme, String mode) { + BadgeTheme selected = themes.get(normalizeTheme(theme)); + return BadgeMode.from(mode, selected.supportedModes(), selected.defaultMode()); } } diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java new file mode 100644 index 0000000..821be1e --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeTheme.java @@ -0,0 +1,264 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import com.tokenphage.api.domain.badge.BadgeCode; +import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; +import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +import com.tokenphage.api.feature.badge.svg.BadgeMode; +import com.tokenphage.api.feature.badge.svg.BadgeTheme; +import com.tokenphage.api.feature.badge.svg.SvgText; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.EnumSet; +import java.util.Set; + +/** + * macOS 터미널 창 모양의 베타 테스터 배지 (310×134, 자격 부여 전용, 단독 테마). + *

+ * 표시 값 전부를 고정 스냅샷(BADGE_SNAPSHOT)에서 읽어 렌더 시점 집계가 없다. + * 좌표·색은 badge-basic.svg.txt 실측값. 애니메이션은 reduced-motion 지원과 + * 타이핑 효과(clip-path + steps) 재현을 위해 SMIL 대신 인라인 CSS를 쓴다. + */ +@Slf4j +@Component +public class BetaTesterBadgeTheme implements BadgeTheme { + + /** 본문 좌측 원점 x — 커서 위치 계산의 기준. */ + private static final int BODY_X = 95; + + /** 유저명(16px 모노) 글리프 폭 — 프로토타입 7자 커서 x=176 역산 (95 + 8×10.125). */ + private static final double NAME_CHAR_WIDTH = 10.125; + + /** 유저명 표시 상한 — 19자일 때 커서 x=297.5로 우측 경계(298) 안. */ + private static final int MAX_NAME_CHARS = 19; + + /** 커서 rect 상단 y — 프로토타입 54에서 상단 여백 정리로 12 올렸다. */ + private static final int CURSOR_Y = 42; + + /** 모노스페이스 폰트 스택 — 터미널 정체성 유지 (프로토타입 그대로, 기존 Pretendard 스택 미사용). */ + private static final String FONT = "'SF Mono',SFMono-Regular,Menlo,Consolas,'Liberation Mono',monospace"; + + /** 기간 텍스트 시작 x — 배너("BETA TESTER" 10px 모노 11자) 우측 끝 161 + 여백 8. */ + private static final int PERIOD_X = 169; + + /** 통계 3칸 x — 라벨 7px 폭(BADGE SERVED 12자 = 57.6)이 다음 열을 침범하지 않는 간격. */ + private static final int[] STAT_XS = {95, 152, 224}; + + /** 통계 라벨 3종 (STAT_XS와 같은 순서). */ + private static final String[] STAT_LABELS = {"SYNCS RUN", "BADGE SERVED", "TOKENS ADDED"}; + + /** macOS 신호등 색 (좌→우, 프로토타입 실측). */ + private static final String TRAFFIC_RED = "#FF5F57"; + private static final String TRAFFIC_YELLOW = "#FEBC2E"; + private static final String TRAFFIC_GREEN = "#28C840"; + + /** + * 테마 식별자를 반환한다. + * + * @return "beta-tester" + * @Since 2026-08-23 + */ + @Override + public String name() { + return BadgeCode.BETA_TESTER.getCode(); + } + + /** + * 고정 스냅샷 하나만 요구한다 — 라이브 집계 데이터를 쓰지 않는다. + * + * @return BADGE_SNAPSHOT 단일 집합 + * @Since 2026-08-23 + */ + @Override + public Set needs() { + return EnumSet.of(BadgeDataNeed.BADGE_SNAPSHOT); + } + + /** + * 지원 색상 모드 — 악센트 3종. light/dark는 지원하지 않는다 (배경이 항상 다크다). + * + * @return CYAN·GREEN·PURPLE + * @Since 2026-08-23 + */ + @Override + public Set supportedModes() { + return EnumSet.of(BadgeMode.CYAN, BadgeMode.GREEN, BadgeMode.PURPLE); + } + + /** + * 기본 모드 — cyan. + * + * @return CYAN + * @Since 2026-08-23 + */ + @Override + public BadgeMode defaultMode() { + return BadgeMode.CYAN; + } + + /** + * 스냅샷 데이터로 터미널 배지 SVG를 생성한다. + *

+ * 스냅샷이 비어 있으면 통계 3칸은 "-", 순번·기간은 미표시로 그린다(잠금 배지로 바꾸지 않는다). + * + * @param data 배지 데이터 (snapshot 필드 사용, null 불허) + * @param mode 색상 모드 (지원 집합 밖이면 cyan으로 접힘) + * @return 완성된 SVG 마크업 문자열 + * @Since 2026-08-23 + */ + @Override + public String build(BadgeResponse data, BadgeMode mode) { + BadgeMode resolved = supportedModes().contains(mode) ? mode : defaultMode(); + BetaTesterColors c = BetaTesterColors.forMode(resolved); + BetaTesterSnapshot snap = BetaTesterSnapshot.parse(data.snapshot()); + String modeKey = resolved.getCode(); + String displayName = truncateName(data.username()); + log.debug("Building beta tester badge: user={}, mode={}", data.username(), modeKey); + + StringBuilder sb = new StringBuilder(); + appendRoot(sb, data.username()); + appendStyle(sb, modeKey); + appendDefs(sb, modeKey, c); + sb.append("".formatted(SvgText.LINK_URL, SvgText.LINK_URL)); + sb.append("".formatted(modeKey)); + appendChrome(sb, c); + sb.append(BetaTesterMascot.render(resolved)); + sb.append(BetaTesterProviderMark.render(snap.isClaudeUse(), snap.isGptUse(), c)); + appendSidebarRank(sb, snap, c); + appendBanner(sb, snap, c); + appendUsername(sb, displayName, modeKey, c); + appendStats(sb, snap, c); + appendPrompt(sb, modeKey, c); + sb.append(("").formatted(modeKey, modeKey)); + sb.append(""); + sb.append("" + .formatted(c.border())); + sb.append(""); + return sb.toString(); + } + + /** + * 루트 svg. 캔버스는 310×122, 표시 크기는 410×161. + * 프로토타입 148에서 상·하 여백을 각 11~12로 맞추며 26을 줄였고 본문은 12 올렸다. + */ + private void appendRoot(StringBuilder sb, String username) { + sb.append(""" + + """.formatted(SvgText.escape(username))); + } + + /** + * 커서 깜빡임·스캔라인·타이핑 keyframes. 클래스·keyframes 이름에 모드 접미사를 붙여 + * 한 페이지 다중 임베드 시 충돌을 막고, prefers-reduced-motion에서 전부 정지한다. + */ + private void appendStyle(StringBuilder sb, String modeKey) { + sb.append(""" + + """.formatted(modeKey)); + } + + private void appendDefs(StringBuilder sb, String modeKey, BetaTesterColors c) { + sb.append(""" + + + + + """.formatted(modeKey, c.accent())); + } + + private void appendChrome(StringBuilder sb, BetaTesterColors c) { + sb.append(""" + + + + + tokenphage — beta + + """.formatted(c.bg(), c.titleBar(), c.divider(), + TRAFFIC_RED, TRAFFIC_YELLOW, TRAFFIC_GREEN, + FONT, c.textSecondary(), c.divider())); + } + + private void appendSidebarRank(StringBuilder sb, BetaTesterSnapshot snap, BetaTesterColors c) { + if (snap.signupRank() <= 0) { + return; + } + sb.append(""" + #%d + """.formatted(FONT, c.accent(), snap.signupRank())); + } + + private void appendBanner(StringBuilder sb, BetaTesterSnapshot snap, BetaTesterColors c) { + sb.append(""" + BETA TESTER + """.formatted(FONT, c.textPrimary(), c.accent())); + if (!snap.period().isBlank()) { + // 배너와 같은 베이스라인에 이어 붙인다 (우측 끝 정렬에서 변경). + sb.append(""" + %s + """.formatted(PERIOD_X, FONT, c.textMuted(), SvgText.escape(snap.period()))); + } + } + + private void appendUsername(StringBuilder sb, String displayName, String modeKey, BetaTesterColors c) { + sb.append(""" + @%s + """.formatted(FONT, c.textPrimary(), c.accent(), SvgText.escape(displayName))); + // 커서 x = 95 + (@ 포함 글리프 수) × 글리프 폭 — 7자에서 프로토타입 실측 176과 일치 + double cursorX = BODY_X + (displayName.length() + 1) * NAME_CHAR_WIDTH; + sb.append(""" + + + """.formatted(modeKey, trimNumber(cursorX), CURSOR_Y, c.accent(), c.divider())); + } + + private void appendStats(StringBuilder sb, BetaTesterSnapshot snap, BetaTesterColors c) { + sb.append(""" + CONTRIBUTED DURING BETA + """.formatted(FONT, c.textMuted())); + // 스냅샷 문자열을 그대로 출력한다 — 표기가 적재 시점에 고정돼 formatTokens를 쓰지 않는다. + // DB에서 온 자유 문자열이라 전부 escape를 통과시킨다. + String[] values = {snap.syncsRun(), snap.badgeServed(), snap.tokensAdded()}; + for (int i = 0; i < STAT_XS.length; i++) { + sb.append(""" + %s + %s + """.formatted(STAT_XS[i], FONT, c.accent(), SvgText.escape(values[i]), + STAT_XS[i], FONT, c.textSecondary(), STAT_LABELS[i])); + } + } + + private void appendPrompt(StringBuilder sb, String modeKey, BetaTesterColors c) { + sb.append(""" + $ + echo "Thanks for shaping the beta." + """.formatted(FONT, c.accent(), modeKey, FONT, c.textBody())); + } + + private String truncateName(String username) { + if (username.length() <= MAX_NAME_CHARS) { + return username; + } + return username.substring(0, MAX_NAME_CHARS - 1) + "…"; + } + + /** + * 좌표 수치를 SVG 속성 문자열로 만든다 — 정수면 소수점 없이(176), 아니면 그대로(297.5). + */ + private static String trimNumber(double value) { + if (value == Math.rint(value)) { + return String.valueOf((long) value); + } + return String.valueOf(value); + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterColors.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterColors.java new file mode 100644 index 0000000..117a87d --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterColors.java @@ -0,0 +1,44 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import com.tokenphage.api.feature.badge.svg.BadgeMode; + +/** + * 터미널 배지 팔레트 (badge-{basic,green,purple}.svg.txt 프로토타입 실측). + *

+ * mascotGlow는 필터 문자열 전체 — 세 모드가 같은 글로우 수치를 쓰고 색만 다르다. + */ +public record BetaTesterColors( + String bg, String titleBar, String border, String divider, + String textPrimary, String textSecondary, String textMuted, String textBody, + String accent, String mascotGlow) { + + public static final BetaTesterColors CYAN = new BetaTesterColors( + "#1E1E1E", "#2D2D2F", "#48484A", "#3A3A3C", + "#F5F5F7", "#98989D", "#7C7C82", "#C7C7CC", + "#64D2FF", "drop-shadow(0 0 2px rgba(245,245,247,.3)) drop-shadow(0 0 4px rgba(245,245,247,.2))"); + + public static final BetaTesterColors GREEN = new BetaTesterColors( + "#1E1E1E", "#2D2D2F", "#48484A", "#3A3A3C", + "#F5F5F7", "#98989D", "#7C7C82", "#C7C7CC", + "#64DC82", "drop-shadow(0 0 2px rgba(100,220,130,.3)) drop-shadow(0 0 4px rgba(100,220,130,.2))"); + + public static final BetaTesterColors PURPLE = new BetaTesterColors( + "#1E1E1E", "#2D2D2F", "#48484A", "#3A3A3C", + "#F5F5F7", "#98989D", "#7C7C82", "#C7C7CC", + "#D08BF5", "drop-shadow(0 0 2px rgba(208,139,245,.3)) drop-shadow(0 0 4px rgba(208,139,245,.2))"); + + /** + * 모드에 대응하는 팔레트를 반환한다. 악센트 3종 밖의 모드는 CYAN으로 접는다. + * + * @param mode 색상 모드 (null 불허) + * @return 모드 팔레트 + * @Since 2026-08-23 + */ + public static BetaTesterColors forMode(BadgeMode mode) { + return switch (mode) { + case GREEN -> GREEN; + case PURPLE -> PURPLE; + default -> CYAN; + }; + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascot.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascot.java new file mode 100644 index 0000000..b0ef05b --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascot.java @@ -0,0 +1,51 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import com.tokenphage.api.feature.badge.svg.BadgeMode; + +/** + * 픽셀 파지 마스코트 (16×14 격자, rect 42개). + *

+ * rect 블록은 badge-basic.svg.txt 프로토타입에서 스크립트로 추출했다 + * (rect 42개·좌우대칭·round-trip diff 0 검증 완료). 손으로 수정하지 않는다. + *

+ * shape-rendering="crispEdges"를 넣지 않는다 — 프로토타입이 지정하지 않았고 + * scale(3.8125)가 비정수라 픽셀 행 높이가 불균등해진다 (의도적 예외). + */ +final class BetaTesterMascot { + + /** 배치 변환 — 프로토타입 translate(11,42) 기준 상단 여백 정리로 12 조정. */ + private static final String TRANSFORM = "translate(11,30) scale(3.8125)"; + + /** 픽셀 rect 42개 — 본체 #F5F5F7, 음영(눈) #1E1E1E. */ + private static final String PIXEL_RECTS = """ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + """; + + private BetaTesterMascot() { + } + + /** + * 모드 팔레트의 글로우 필터를 입힌 마스코트 SVG 조각을 반환한다. + * + * @param mode 색상 모드 (악센트 3종 밖이면 cyan 팔레트로 접힘) + * @return 마스코트 SVG 조각 + * @Since 2026-08-23 + */ + static String render(BadgeMode mode) { + BetaTesterColors c = BetaTesterColors.forMode(mode); + return "%s" + .formatted(c.mascotGlow(), TRANSFORM, PIXEL_RECTS); + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterProviderMark.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterProviderMark.java new file mode 100644 index 0000000..ac85276 --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterProviderMark.java @@ -0,0 +1,95 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +/** + * 사용 모델 로고(Claude·OpenAI) 배치 유틸. + *

+ * 판정은 스냅샷 적재 시점에 끝나 있다 — 여기서는 boolean 두 개로 배치만 결정한다. + * path 2개는 badge-basic.svg.txt 프로토타입에서 스크립트로 추출했다. 손으로 수정하지 않는다. + */ +final class BetaTesterProviderMark { + + /** 로고 2개일 때 좌측(Claude) x (프로토타입 실측). */ + private static final String PAIR_LEFT_X = "31.5"; + + /** 로고 2개일 때 우측(OpenAI) x (프로토타입 실측). */ + private static final String PAIR_RIGHT_X = "44.5"; + + /** 로고 1개일 때 x — 순번 텍스트와 같은 중심 42 유지 (로고 폭 8px = 24 × 0.3333). */ + private static final String SINGLE_X = "38"; + + /** 로고 상단 y — 프로토타입 100 기준 상단 여백 정리로 12 조정. */ + private static final int MARK_Y = 88; + + /** 로고 축소 배율 (프로토타입 실측). */ + private static final String MARK_SCALE = "0.3333"; + + /** Claude 로고 fill — 모드와 무관한 브랜드색. */ + private static final String CLAUDE_FILL = "#D97757"; + + /** Claude 로고 path d (프로토타입 추출). */ + private static final String CLAUDE_MARK = """ + M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 \ + 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103\ + -.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.\ + 686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.1\ + 7-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.45\ + 6.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-\ + .067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.4\ + 31h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.5\ + 43-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.6\ + 62.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.09\ + 3 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.\ + 444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.\ + 674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042\ + -.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.8\ + 82.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312z"""; + + /** OpenAI 로고 path d (프로토타입 추출). */ + private static final String OPENAI_MARK = """ + M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 \ + 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4\ + .9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.20\ + 58 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 \ + 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.\ + 071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-\ + 3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0\ + 615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11\ + .6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.\ + 504 4.504 0 0 1 2.3408 7.8956zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.83\ + 03 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852\ + -4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.78\ + 66a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.\ + 4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.\ + 602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"""; + + private BetaTesterProviderMark() { + } + + /** + * 사용 모델 조합에 맞는 로고 SVG 조각을 반환한다. + * + * @param isClaudeUse 베타 기간에 claude 계열 모델을 사용했는지 + * @param isGptUse 베타 기간에 gpt 계열 모델을 사용했는지 + * @param colors 현재 팔레트 (OpenAI 로고 fill에 사용) + * @return 로고 SVG 조각, 둘 다 false면 빈 문자열 + * @Since 2026-08-23 + */ + static String render(boolean isClaudeUse, boolean isGptUse, BetaTesterColors colors) { + if (isClaudeUse && isGptUse) { + return mark(PAIR_LEFT_X, CLAUDE_MARK, CLAUDE_FILL) + + mark(PAIR_RIGHT_X, OPENAI_MARK, colors.textPrimary()); + } + if (isClaudeUse) { + return mark(SINGLE_X, CLAUDE_MARK, CLAUDE_FILL); + } + if (isGptUse) { + return mark(SINGLE_X, OPENAI_MARK, colors.textPrimary()); + } + return ""; + } + + private static String mark(String x, String path, String fill) { + return "" + .formatted(x, MARK_Y, MARK_SCALE, path, fill); + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterSnapshot.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterSnapshot.java new file mode 100644 index 0000000..9807590 --- /dev/null +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterSnapshot.java @@ -0,0 +1,87 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * beta-tester 배지의 스냅샷 payload 스키마 (badge_snapshot.payload JSON). + *

+ * 스키마 해석은 이 배지 패키지가 소유한다 — 조회 계층은 JSON을 문자열로만 다룬다. + * 키 이름은 V7 적재 쿼리의 jsonb_build_object와 1:1로 일치해야 한다. + * + * @param signupRank 가입 순위 (users.created_at 오름차순, 0 이하면 미표시) + * @param period 베타 기간 표시 문자열 (빈 문자열이면 미표시) + * @param syncsRun 동기화 횟수 표시 문자열 + * @param badgeServed 배지 서빙 횟수 표시 문자열 (단위 축약 완료 상태) + * @param tokensAdded 누적 토큰 표시 문자열 (단위 축약 완료 상태) + * @param isClaudeUse 베타 기간 claude 계열 모델 사용 여부 + * @param isGptUse 베타 기간 gpt 계열 모델 사용 여부 + */ +@JsonIgnoreProperties(ignoreUnknown = true) +record BetaTesterSnapshot(int signupRank, String period, String syncsRun, String badgeServed, + String tokensAdded, boolean isClaudeUse, boolean isGptUse) { + + private static final Logger log = LoggerFactory.getLogger(BetaTesterSnapshot.class); + + /** 스프링 빈 대신 정적 보관 — 테마 생성자에 인자가 붙으면 "new로 생성" 테스트 정책이 깨진다. */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 통계 결손 시 자리 표시 문자열. */ + private static final String PLACEHOLDER = "-"; + + /** + * payload JSON을 파싱한다. null·공백·깨진 JSON이면 {@link #empty()}로 접고 예외를 던지지 않는다. + * + * @param json badge_snapshot.payload 원문 (null 허용) + * @return 정규화된 스냅샷 (문자열 필드는 null이 아님) + * @Since 2026-08-23 + */ + static BetaTesterSnapshot parse(String json) { + if (json == null || json.isBlank()) { + return empty(); + } + try { + return MAPPER.readValue(json, BetaTesterSnapshot.class).normalized(); + } catch (JsonProcessingException e) { + log.warn("Failed to parse beta tester snapshot payload, rendering empty: {}", e.getMessage()); + return empty(); + } + } + + /** + * 스냅샷이 없거나 깨졌을 때 쓰는 빈 스냅샷을 반환한다. + *

+ * 순번·기간은 미표시 판정값(0/""), 통계 3칸은 자리 표시 "-"다. + * 없는 값을 0이라고 주장하지 않기 위해 0을 쓰지 않는다. + * + * @return 빈 스냅샷 + * @Since 2026-08-23 + */ + static BetaTesterSnapshot empty() { + return new BetaTesterSnapshot(0, "", PLACEHOLDER, PLACEHOLDER, PLACEHOLDER, false, false); + } + + /** + * null·공백 문자열 필드를 표시 가능한 값으로 접는다 (테마가 null을 만나지 않게). + */ + private BetaTesterSnapshot normalized() { + return new BetaTesterSnapshot( + signupRank, + period == null ? "" : period, + blankToPlaceholder(syncsRun), + blankToPlaceholder(badgeServed), + blankToPlaceholder(tokensAdded), + isClaudeUse, + isGptUse); + } + + private static String blankToPlaceholder(String value) { + if (value == null || value.isBlank()) { + return PLACEHOLDER; + } + return value; + } +} diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/card/CardBadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/card/CardBadgeTheme.java index 69c8f50..0ad0fc5 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/card/CardBadgeTheme.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/card/CardBadgeTheme.java @@ -4,6 +4,7 @@ import com.tokenphage.api.feature.badge.dto.response.DailyCountResponse; import com.tokenphage.api.feature.badge.dto.response.ModelCountResponse; import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +import com.tokenphage.api.feature.badge.svg.BadgeMode; import com.tokenphage.api.feature.badge.svg.BadgeTheme; import com.tokenphage.api.feature.badge.svg.SvgText; import lombok.extern.slf4j.Slf4j; @@ -38,17 +39,42 @@ public Set needs() { BadgeDataNeed.CACHE_HIT_RATE); // 프롬프트 캐싱 Hit 비율 } + /** + * 지원 색상 모드를 선언한다. + * + * @return LIGHT·DARK + * @Since 2026-08-23 + */ + @Override + public final Set supportedModes() { + return EnumSet.of(BadgeMode.LIGHT, BadgeMode.DARK); + } + + /** + * 기본 색상 모드를 반환한다. + * + * @return LIGHT + * @Since 2026-08-23 + */ + @Override + public final BadgeMode defaultMode() { + return BadgeMode.LIGHT; + } + /** * 공통 레이아웃에 테마별 마스코트를 끼워 완성된 배지 SVG를 생성한다. + *

+ * 패밀리 내부 훅은 boolean isDark 계약을 유지한다 — 모드 공간이 두 개뿐이라 정확하다. * * @param data 배지에 표시할 사용자 데이터 (null 불허) - * @param isDark true면 다크 모드, false면 라이트 모드 + * @param mode 색상 모드 (DARK 외에는 라이트로 그린다) * @return 완성된 SVG 마크업 문자열 * @Since 2026-05-31 */ @Override - public final String build(BadgeResponse data, boolean isDark) { + public final String build(BadgeResponse data, BadgeMode mode) { + boolean isDark = mode == BadgeMode.DARK; CardColors modeColor = isDark ? CardColors.DARK : CardColors.LIGHT; log.debug("Building badge: user={}, theme={}, isDark={}", data.username(), name(), isDark); diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/grass/GrassBadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/grass/GrassBadgeTheme.java index f7abcce..9f660f2 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/grass/GrassBadgeTheme.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/grass/GrassBadgeTheme.java @@ -3,6 +3,7 @@ import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.dto.response.DailyCountResponse; import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +import com.tokenphage.api.feature.badge.svg.BadgeMode; import com.tokenphage.api.feature.badge.svg.BadgeTheme; import com.tokenphage.api.feature.badge.svg.SvgText; import lombok.extern.slf4j.Slf4j; @@ -427,18 +428,43 @@ public abstract class GrassBadgeTheme implements BadgeTheme { */ private static final String FLICKER_DUR = "0.9s"; + /** + * 지원 색상 모드를 선언한다. + * + * @return LIGHT·DARK + * @Since 2026-08-23 + */ + @Override + public final Set supportedModes() { + return EnumSet.of(BadgeMode.LIGHT, BadgeMode.DARK); + } + + /** + * 기본 색상 모드를 반환한다. + * + * @return LIGHT + * @Since 2026-08-23 + */ + @Override + public final BadgeMode defaultMode() { + return BadgeMode.LIGHT; + } + /** * 공통 잔디 레이아웃에 테마별 마스코트·팔레트를 끼워 완성된 뱃지 SVG를 생성한다. + *

+ * 패밀리 내부 훅은 boolean isDark 계약을 유지한다 — 모드 공간이 두 개뿐이라 정확하다. * - * @param data 배지에 표시할 사용자 데이터 (null 불허) - * @param isDark true면 다크 모드, false면 라이트 모드 + * @param data 배지에 표시할 사용자 데이터 (null 불허) + * @param mode 색상 모드 (DARK 외에는 라이트로 그린다) * @return 완성된 SVG 마크업 문자열 * @Since 2026-07-15 */ @Override - public final String build(BadgeResponse data, boolean isDark) { + public final String build(BadgeResponse data, BadgeMode mode) { + boolean isDark = mode == BadgeMode.DARK; GrassColors c = colors(isDark); - String mode = isDark ? "dark" : "light"; + String modeKey = mode.getCode(); log.debug("Building grass badge: user={}, theme={}, isDark={}", data.username(), name(), isDark); StringBuilder sb = new StringBuilder(); @@ -457,7 +483,7 @@ public final String build(BadgeResponse data, boolean isDark) { """.formatted(c.bg(), c.border())); appendHeader(sb, data, c); - appendSky(sb, data, isDark, mode, c); + appendSky(sb, data, isDark, modeKey, c); sb.append(""" """.formatted(c.divider())); diff --git a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/locked/LockedBadgeTheme.java b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/locked/LockedBadgeTheme.java index c62ab5f..ef7a891 100644 --- a/src/main/java/com/tokenphage/api/feature/badge/svg/theme/locked/LockedBadgeTheme.java +++ b/src/main/java/com/tokenphage/api/feature/badge/svg/theme/locked/LockedBadgeTheme.java @@ -3,11 +3,13 @@ import com.tokenphage.api.domain.badge.BadgeCode; import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +import com.tokenphage.api.feature.badge.svg.BadgeMode; import com.tokenphage.api.feature.badge.svg.BadgeTheme; import com.tokenphage.api.feature.badge.svg.SvgText; import com.tokenphage.api.feature.badge.svg.theme.card.CardColors; import org.springframework.stereotype.Component; +import java.util.EnumSet; import java.util.Set; /** @@ -47,19 +49,41 @@ public Set needs() { return Set.of(); } + /** + * 지원 색상 모드를 선언한다. + * + * @return LIGHT·DARK + * @Since 2026-08-23 + */ + @Override + public Set supportedModes() { + return EnumSet.of(BadgeMode.LIGHT, BadgeMode.DARK); + } + + /** + * 기본 색상 모드를 반환한다. + * + * @return LIGHT + * @Since 2026-08-23 + */ + @Override + public BadgeMode defaultMode() { + return BadgeMode.LIGHT; + } + /** * 기본 안내 문구로 그린다. *

* ?theme=locked 직접 호출 경로. 자격 거부 시에는 {@link #render}가 배지별 문구를 받아 그린다. * - * @param data 쓰지 않는다 - * @param isDark true면 다크 모드 + * @param data 쓰지 않는다 + * @param mode 색상 모드 (DARK 외에는 라이트로 그린다) * @return 기본 문구 잠금 안내 SVG * @Since 2026-08-10 */ @Override - public String build(BadgeResponse data, boolean isDark) { - return render(DEFAULT_TITLE, DEFAULT_MESSAGE, isDark); + public String build(BadgeResponse data, BadgeMode mode) { + return render(DEFAULT_TITLE, DEFAULT_MESSAGE, mode == BadgeMode.DARK); } /** diff --git a/src/main/resources/db/migration/V6__create_badge_snapshot.sql b/src/main/resources/db/migration/V6__create_badge_snapshot.sql new file mode 100644 index 0000000..d213a7e --- /dev/null +++ b/src/main/resources/db/migration/V6__create_badge_snapshot.sql @@ -0,0 +1,27 @@ +-- 값이 고정된 배지의 미리 계산된 표시 데이터. 렌더 시점 집계를 대체한다. +CREATE TABLE badge_snapshot ( + github_id BIGINT NOT NULL REFERENCES users(github_id), + badge_code VARCHAR(40) NOT NULL REFERENCES badge_catalog(code), + payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (github_id, badge_code) +); + +-- Beta Tester 배지 등록. code는 BadgeCode 상수 및 BadgeTheme.name()과 일치해야 한다. +INSERT INTO badge_catalog (code, display_name, require_grant, locked_message) VALUES + ('beta-tester', 'Beta Tester', true, '베타 테스터 전용 배지입니다'); + +-- 스냅샷 표시 문자열용 단위 축약. 소수 첫째 자리를 유지하되 .0이면 떼어낸다. +-- 프로토타입 표기가 "324.0T"가 아니라 "324T"이므로 SvgText.formatTokens와 결과가 다르다. +CREATE FUNCTION badge_compact_number(v BIGINT) RETURNS TEXT +LANGUAGE sql IMMUTABLE AS $fn$ + SELECT CASE + WHEN v >= 1000000000000 THEN rtrim(rtrim(to_char(v / 1e12, 'FM999999990.9'), '0'), '.') || 'T' + WHEN v >= 1000000000 THEN rtrim(rtrim(to_char(v / 1e9, 'FM999999990.9'), '0'), '.') || 'B' + WHEN v >= 1000000 THEN rtrim(rtrim(to_char(v / 1e6, 'FM999999990.9'), '0'), '.') || 'M' + WHEN v >= 1000 THEN rtrim(rtrim(to_char(v / 1e3, 'FM999999990.9'), '0'), '.') || 'K' + ELSE v::text + END +$fn$; + diff --git a/src/main/resources/db/migration/V7__add_beta_tester_badge.sql b/src/main/resources/db/migration/V7__add_beta_tester_badge.sql new file mode 100644 index 0000000..51479d6 --- /dev/null +++ b/src/main/resources/db/migration/V7__add_beta_tester_badge.sql @@ -0,0 +1,61 @@ +-- beta-tester 데이터 적재 3종: 스냅샷 + 자격 + 이력 (카탈로그 등록은 V6). +-- 베타 참여자의 표시 값을 스냅샷으로 굳힌다. 이 배지는 값이 변하지 않으므로 여기서 한 번만 계산한다. +-- 대상: 2026-08-08 이하 가입자 (created_at < 2026-08-09 00:00 +09) +-- 집계 창: 2026-07-01 ~ 2026-08-07 — 표시 라벨 "CONTRIBUTED DURING BETA"와 범위를 맞춘다 +-- 누적 토큰 정의는 DailyTokenUsageRepository.sumTotalTokens와 동일하다 (input_tok + output_tok, 캐시 제외) +INSERT INTO badge_snapshot (github_id, badge_code, payload) +SELECT b.github_id, + 'beta-tester', + jsonb_build_object( + 'signupRank', b.signup_rank, + 'period', '2026.07 – 2026.08', + 'syncsRun', b.syncs_run::text, + 'badgeServed', badge_compact_number(b.badge_served), + 'tokensAdded', badge_compact_number(b.tokens_added), + 'isClaudeUse', b.is_claude_use, + 'isGptUse', b.is_gpt_use) +FROM ( + SELECT u.github_id, + -- 필터된 집합에서 계산해도 전체 기준과 같다 — 컷오프 이후 가입자는 항상 더 큰 순위다. + (ROW_NUMBER() OVER (ORDER BY u.created_at, u.github_id))::int AS signup_rank, + (SELECT count(*) FROM request_audit_log a + WHERE a.github_id = u.github_id + AND a.feature = 'sync' + AND a.status_code < 400 + AND (a.occurred_at AT TIME ZONE 'Asia/Seoul')::date + BETWEEN DATE '2026-07-01' AND DATE '2026-08-07') AS syncs_run, + -- request_path 접두 비교는 인덱스를 타지 않지만 이 마이그레이션에서 한 번만 돈다. + (SELECT count(*) FROM request_audit_log a + WHERE a.request_path LIKE '/badge/' || u.username || '%' + AND a.status_code < 400 + AND (a.occurred_at AT TIME ZONE 'Asia/Seoul')::date + BETWEEN DATE '2026-07-01' AND DATE '2026-08-07') AS badge_served, + -- sum(bigint)은 numeric을 반환하므로 badge_compact_number(bigint) 시그니처에 맞춰 캐스팅한다. + (SELECT COALESCE(sum(d.input_tok + d.output_tok), 0)::bigint + FROM daily_token_usage d + WHERE d.github_id = u.github_id + AND d.usage_date BETWEEN DATE '2026-07-01' AND DATE '2026-08-07') AS tokens_added, + EXISTS (SELECT 1 FROM daily_token_usage d + WHERE d.github_id = u.github_id + AND d.model ILIKE '%claude%' + AND d.usage_date BETWEEN DATE '2026-07-01' AND DATE '2026-08-07') AS is_claude_use, + EXISTS (SELECT 1 FROM daily_token_usage d + WHERE d.github_id = u.github_id + AND d.model ILIKE '%gpt%' + AND d.usage_date BETWEEN DATE '2026-07-01' AND DATE '2026-08-07') AS is_gpt_use + FROM users u + WHERE u.created_at < TIMESTAMPTZ '2026-08-09 00:00:00+09' +) b; + +-- 베타 참여자 전원에게 자격을 부여한다. 대상 집합은 스냅샷 적재와 동일해야 한다 +-- (어긋나면 "자격은 있는데 스냅샷이 없는" 사용자가 마이그레이션 시점부터 생긴다). +INSERT INTO user_badge_grant (github_id, badge_code, granted_by, grant_note) +SELECT u.github_id, 'beta-tester', 'TKPG-ADMIN', 'closed beta 2026-07~08 participant (V7 bulk grant)' +FROM users u +WHERE u.created_at < TIMESTAMPTZ '2026-08-09 00:00:00+09'; + +-- 부여 이력. 자격 행과 항상 세트로 남긴다 (append-only 감사, action은 CHECK 제약상 GRANT/REVOKE만). +INSERT INTO badge_grant_history (github_id, badge_code, action, actor, reason) +SELECT u.github_id, 'beta-tester', 'GRANT', 'TKPG-ADMIN', 'closed beta 2026-07~08 participant (V7 bulk grant)' +FROM users u +WHERE u.created_at < TIMESTAMPTZ '2026-08-09 00:00:00+09'; diff --git a/src/test/java/com/tokenphage/api/domain/badge/BadgeCatalogConsistencyTest.java b/src/test/java/com/tokenphage/api/domain/badge/BadgeCatalogConsistencyTest.java index c56b0d4..0c70e84 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/BadgeCatalogConsistencyTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/BadgeCatalogConsistencyTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.tokenphage.api.feature.badge.svg.BadgeTheme; +import com.tokenphage.api.feature.badge.svg.theme.betatester.BetaTesterBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.card.claude.CardClaudeBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.card.gpu.CardGpuBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.grass.claude.GrassClaudeBadgeTheme; @@ -20,14 +21,17 @@ import org.junit.jupiter.api.Test; /** - * 배지 코드가 enum · 테마 클래스 · DB 초기 데이터(V5) 세 곳에서 같은지 검증한다. + * 배지 코드가 enum · 테마 클래스 · DB 초기 데이터(마이그레이션 시드) 세 곳에서 같은지 검증한다. *

* 하나라도 빠뜨리면 예외 없이 잠금 배지만 나온다. * 서버 없이 확인 가능하므로 운영 코드가 아닌 테스트로 잡는다. */ class BadgeCatalogConsistencyTest { - private static final String MIGRATION_PATH = "/db/migration/V5__create_badge_catalog_and_grant.sql"; + /** badge_catalog 시드 INSERT를 담은 마이그레이션 목록. 새 배지의 시드 V파일을 여기에 추가한다. */ + private static final List MIGRATION_PATHS = List.of( + "/db/migration/V5__create_badge_catalog_and_grant.sql", + "/db/migration/V6__create_badge_snapshot.sql"); /** INSERT 문 각 행에서 첫 번째 컬럼(code)만 뽑는다. 예: ('gpu', 'GPU Card', false, NULL) */ private static final Pattern SEED_ROW = Pattern.compile("\\(\\s*'([a-z0-9-]+)'\\s*,"); @@ -41,19 +45,20 @@ private List themeBeans() { new CardGpuBadgeTheme(), new CardClaudeBadgeTheme(), new GrassClaudeBadgeTheme(), + new BetaTesterBadgeTheme(), new LockedBadgeTheme()); } /** - * V5 마이그레이션에서 badge_catalog INSERT를 찾아 배지 코드를 파싱한다. + * 지정 마이그레이션에서 주석 줄을 제거한 본문을 읽는다. *

* 클래스패스 리소스라 클린 체크아웃·CI에서도 동작한다. * 주석 처리된 예시 INSERT를 세지 않도록 주석 줄을 먼저 제거한다. */ - private String migrationWithoutComments() throws IOException { - try (InputStream in = getClass().getResourceAsStream(MIGRATION_PATH)) { + private String migrationWithoutComments(String path) throws IOException { + try (InputStream in = getClass().getResourceAsStream(path)) { assertThat(in) - .as("마이그레이션 리소스를 찾을 수 없다: %s", MIGRATION_PATH) + .as("마이그레이션 리소스를 찾을 수 없다: %s", path) .isNotNull(); String sql = new String(in.readAllBytes(), StandardCharsets.UTF_8); return sql.lines() @@ -63,24 +68,32 @@ private String migrationWithoutComments() throws IOException { } /** - * 시드 INSERT 블록(세미콜론까지)만 잘라낸다. + * 모든 마이그레이션의 badge_catalog 시드 INSERT 블록(세미콜론 전까지)을 병합한다. */ - private String seedBlock() throws IOException { - String sql = migrationWithoutComments(); - int insertAt = sql.indexOf("INSERT INTO badge_catalog"); - assertThat(insertAt).as("badge_catalog 시드 INSERT를 찾을 수 없다").isNotNegative(); - - String block = sql.substring(insertAt); - int end = block.indexOf(';'); - assertThat(end).as("시드 INSERT가 세미콜론으로 끝나지 않는다").isNotNegative(); - return block.substring(0, end); + private String seedBlocks() throws IOException { + StringBuilder blocks = new StringBuilder(); + for (String path : MIGRATION_PATHS) { + String sql = migrationWithoutComments(path); + int searchFrom = 0; + boolean found = false; + int insertAt; + while ((insertAt = sql.indexOf("INSERT INTO badge_catalog", searchFrom)) >= 0) { + int end = sql.indexOf(';', insertAt); + assertThat(end).as("시드 INSERT가 세미콜론으로 끝나지 않는다: %s", path).isNotNegative(); + blocks.append(sql, insertAt, end).append('\n'); + searchFrom = end; + found = true; + } + assertThat(found).as("badge_catalog 시드 INSERT를 찾을 수 없다: %s", path).isTrue(); + } + return blocks.toString(); } /** * DB 초기 데이터에 등록된 배지 코드 전부를 반환한다. */ private Set seedCodes() throws IOException { - Matcher matcher = SEED_ROW.matcher(seedBlock()); + Matcher matcher = SEED_ROW.matcher(seedBlocks()); return matcher.results() .map(r -> r.group(1)) .collect(Collectors.toUnmodifiableSet()); @@ -92,7 +105,7 @@ private Set seedCodes() throws IOException { private boolean requireGrantOf(String code) throws IOException { Pattern row = Pattern.compile( "\\(\\s*'" + Pattern.quote(code) + "'\\s*,[^,]+,\\s*(true|false)\\s*,"); - Matcher matcher = row.matcher(seedBlock()); + Matcher matcher = row.matcher(seedBlocks()); assertThat(matcher.find()).as("초기 데이터에서 %s 행을 찾을 수 없다", code).isTrue(); return Boolean.parseBoolean(matcher.group(1)); } @@ -196,4 +209,24 @@ class DefaultThemeTest { assertThat(requireGrantOf(lockedCode)).isFalse(); } } + + @Nested + @DisplayName("자격 배지가 갖춰야 할 조건") + class GrantBadgeTest { + + @Test + @DisplayName("자격배지_betaTester_자격을요구함") + void 자격배지_betaTester_자격을요구함() throws IOException { + // given + // 베타 테스터 배지는 운영자 부여 전용이다. 공개로 풀리면 스냅샷 없는 배지가 노출된다. + String code = BadgeCode.BETA_TESTER.getCode(); + + // when + Set seeded = seedCodes(); + + // then + assertThat(seeded).contains(code); + assertThat(requireGrantOf(code)).isTrue(); + } + } } diff --git a/src/test/java/com/tokenphage/api/domain/badge/BadgeCodeTest.java b/src/test/java/com/tokenphage/api/domain/badge/BadgeCodeTest.java index 5bd16c7..1315df2 100644 --- a/src/test/java/com/tokenphage/api/domain/badge/BadgeCodeTest.java +++ b/src/test/java/com/tokenphage/api/domain/badge/BadgeCodeTest.java @@ -27,6 +27,7 @@ class GetCodeTest { "GPU, gpu", "CLAUDE, claude", "GRASS_CLAUDE, grass-claude", + "BETA_TESTER, beta-tester", "LOCKED, locked" }) @DisplayName("코드조회_각상수_정해진문자열반환") @@ -59,15 +60,16 @@ class GetCodeTest { class AllCodesTest { @Test - @DisplayName("전체코드_호출_등록된4종반환") - void 전체코드_호출_등록된4종반환() { + @DisplayName("전체코드_호출_등록된5종반환") + void 전체코드_호출_등록된5종반환() { // given // when Set result = BadgeCode.allCodes(); // then - assertThat(result).containsExactlyInAnyOrder("gpu", "claude", "grass-claude", "locked"); + assertThat(result).containsExactlyInAnyOrder( + "gpu", "claude", "grass-claude", "beta-tester", "locked"); } @Test diff --git a/src/test/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotIdTest.java b/src/test/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotIdTest.java new file mode 100644 index 0000000..259ba83 --- /dev/null +++ b/src/test/java/com/tokenphage/api/domain/badge/repository/entity/BadgeSnapshotIdTest.java @@ -0,0 +1,177 @@ +package com.tokenphage.api.domain.badge.repository.entity; + +import static org.assertj.core.api.Assertions.assertThat; + +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.ValueSource; + +/** + * 스냅샷 복합키(github_id + badge_code)의 equals()/hashCode()를 검증한다. + *

+ * JPA가 같은 행인지 구분하는 기준이다. 어긋나면 중복 조회·중복 저장이 발생한다. + */ +class BadgeSnapshotIdTest { + + // 두 필드가 모두 같은 기준 인스턴스를 만들 때 쓰는 값 + private static final Long GITHUB_ID = -1L; + private static final String BADGE_CODE = "beta-tester"; + + private BadgeSnapshotId newId(Long githubId, String badgeCode) { + return new BadgeSnapshotId(githubId, badgeCode); + } + + /** + * 기준값(두 필드 모두 동일)으로 채운 인스턴스를 만든다. + */ + private BadgeSnapshotId baseId() { + return newId(GITHUB_ID, BADGE_CODE); + } + + @Nested + @DisplayName("equals() 검증") + class EqualsTest { + + @Test + @DisplayName("equals_두필드모두동일_true반환") + void equals_두필드모두동일_true반환() { + // given + BadgeSnapshotId a = baseId(); + BadgeSnapshotId b = baseId(); + + // when + boolean result = a.equals(b); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("equals_자기자신비교_true반환") + void equals_자기자신비교_true반환() { + // given + BadgeSnapshotId a = baseId(); + + // when + boolean result = a.equals(a); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("equals_a와b상호비교_결과동일") + void equals_a와b상호비교_결과동일() { + // given + BadgeSnapshotId a = baseId(); + BadgeSnapshotId b = baseId(); + + // when + boolean aEqualsB = a.equals(b); + boolean bEqualsA = b.equals(a); + + // then + assertThat(aEqualsB).isEqualTo(bEqualsA); + assertThat(aEqualsB).isTrue(); + } + + @ParameterizedTest(name = "[{index}] {0} 필드만 다르면 false") + @ValueSource(strings = {"githubId", "badgeCode"}) + @DisplayName("equals_각필드별로하나만다름_false반환") + void equals_각필드별로하나만다름_false반환(String differingField) { + // given + BadgeSnapshotId a = baseId(); + // 지정된 한 필드만 다른 값으로 만든다 + BadgeSnapshotId b = switch (differingField) { + case "githubId" -> newId(-999L, BADGE_CODE); + case "badgeCode" -> newId(GITHUB_ID, "gpu"); + default -> throw new IllegalArgumentException("Unknown field: " + differingField); + }; + + // when + boolean result = a.equals(b); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("equals_null비교_false반환") + void equals_null비교_false반환() { + // given + BadgeSnapshotId a = baseId(); + + // when + boolean result = a.equals(null); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("equals_다른타입비교_false반환") + void equals_다른타입비교_false반환() { + // given + BadgeSnapshotId a = baseId(); + Object other = "not-an-id"; + + // when + boolean result = a.equals(other); + + // then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("hashCode() 검증") + class HashCodeTest { + + @Test + @DisplayName("hashCode_두필드모두동일_동일한해시반환") + void hashCode_두필드모두동일_동일한해시반환() { + // given + BadgeSnapshotId a = baseId(); + BadgeSnapshotId b = baseId(); + + // when + int hashA = a.hashCode(); + int hashB = b.hashCode(); + + // then + assertThat(hashA).isEqualTo(hashB); + } + + @Test + @DisplayName("hashCode_동일인스턴스반복호출_일관된값반환") + void hashCode_동일인스턴스반복호출_일관된값반환() { + // given + BadgeSnapshotId a = baseId(); + + // when + int first = a.hashCode(); + int second = a.hashCode(); + + // then + assertThat(first).isEqualTo(second); + } + + @Test + @DisplayName("hashCode_필드값다름_다른해시반환") + void hashCode_필드값다름_다른해시반환() { + // given + BadgeSnapshotId a = baseId(); + BadgeSnapshotId b = newId(-999L, "gpu"); + + // when + int hashA = a.hashCode(); + int hashB = b.hashCode(); + + // then + // 필수는 아니나, 다른 키가 다른 해시를 가져야 조회 성능이 나온다. + assertThat(hashA).isNotEqualTo(hashB); + } + } +} 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 fe3c7d3..586f040 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 @@ -158,6 +158,24 @@ class ResponseTest { // then verify(badgeRenderService).getSvg(USERNAME, "grass-claude", "dark"); } + + @Test + @DisplayName("배지조회_악센트모드지정_그값으로서비스호출") + void 배지조회_악센트모드지정_그값으로서비스호출() throws Exception { + // given + // 컨트롤러는 mode를 해석하지 않는다 — 악센트 값도 원문 그대로 서비스에 넘긴다. + given(badgeRenderService.getSvg(anyString(), anyString(), anyString())) + .willReturn(new BadgeSvgResponse(SVG, true)); + + // when + mockMvc.perform(get("/badge/{username}", USERNAME) + .param("theme", "beta-tester") + .param("mode", "green")) + .andExpect(status().isOk()); + + // then + verify(badgeRenderService).getSvg(USERNAME, "beta-tester", "green"); + } } @Nested 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 8209805..de6d73c 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 @@ -1,5 +1,6 @@ package com.tokenphage.api.feature.badge.service; +import com.tokenphage.api.domain.badge.repository.BadgeSnapshotRepository; import com.tokenphage.api.domain.token.repository.DailyTokenUsageRepository; import com.tokenphage.api.domain.token.repository.projection.CacheTokenSum; import com.tokenphage.api.domain.token.repository.projection.DailyUsageRow; @@ -49,6 +50,8 @@ class BadgeQueryServiceTest { private static final String TIMEZONE = "Asia/Seoul"; private static final Long GITHUB_ID = -12345L; private static final String USERNAME = "octocat"; + // 기존 시나리오의 배지 코드 — 스냅샷 미요구 경로라 값 자체는 조회에 쓰이지 않는다. + private static final String BADGE_CODE = "gpu"; // gpu/claude 통계 카드 테마의 기본 요구 데이터(4종) — 기존 시나리오는 이 needs로 조회한다. private static final Set GPU_NEEDS = EnumSet.of( BadgeDataNeed.TOTAL_TOKENS, BadgeDataNeed.DAILY_30D, @@ -60,6 +63,9 @@ class BadgeQueryServiceTest { @Mock private DailyTokenUsageRepository tokenRepo; + @Mock + private BadgeSnapshotRepository snapshotRepo; + @InjectMocks private BadgeQueryService service; @@ -117,7 +123,7 @@ class Query_UserLookup { // when // then - assertThatThrownBy(() -> service.query("ghost", GPU_NEEDS)) + assertThatThrownBy(() -> service.query("ghost", BADGE_CODE, GPU_NEEDS)) .isInstanceOf(AppException.class) .satisfies(ex -> assertThat(((AppException) ex).getErrorCode()) .isEqualTo(BadgeErrorCode.USER_NOT_FOUND)); @@ -150,7 +156,7 @@ class Query_HappyPath { .thenReturn(List.of(cache)); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.username()).isEqualTo(USERNAME); @@ -178,7 +184,7 @@ class Query_HappyPath { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.topModels()).isEmpty(); @@ -202,7 +208,7 @@ class Query_TotalTokens { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.totalTokens()).isEqualTo(0L); @@ -221,7 +227,7 @@ class Query_TotalTokens { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.totalTokens()).isEqualTo(987_654L); @@ -252,7 +258,7 @@ class Query_Heatbar { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then // 총 30개, from..today 오름차순으로 채워지고 마지막이 오늘 @@ -281,7 +287,7 @@ class Query_Heatbar { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.daily30d()).hasSize(30); @@ -311,7 +317,7 @@ private void stubBaseWith(List cacheRows) { stubBaseWith(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.cacheHitRate()).isEqualTo(0.0); @@ -324,7 +330,7 @@ private void stubBaseWith(List cacheRows) { stubBaseWith(List.of(cacheRow(null, 20L, 10L))); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.cacheHitRate()).isEqualTo(0.0); @@ -338,7 +344,7 @@ private void stubBaseWith(List cacheRows) { stubBaseWith(List.of(cacheRow(0L, 0L, 0L))); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.cacheHitRate()).isEqualTo(0.0); @@ -352,7 +358,7 @@ private void stubBaseWith(List cacheRows) { stubBaseWith(List.of(cacheRow(70L, 20L, 10L))); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then assertThat(res.cacheHitRate()).isCloseTo(0.7, within(1e-9)); @@ -374,7 +380,7 @@ class Query_NeedsGating { .thenReturn(List.of()); // when - service.query(USERNAME, EnumSet.of( + service.query(USERNAME, BADGE_CODE, EnumSet.of( BadgeDataNeed.DAILY_1Y, BadgeDataNeed.STREAK_DAYS, BadgeDataNeed.YEAR_TOKENS)); // then @@ -399,7 +405,7 @@ class Query_NeedsGating { when(tokenRepo.sumCacheTokens(GITHUB_ID)).thenReturn(List.of()); // when - BadgeResponse res = service.query(USERNAME, GPU_NEEDS); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, GPU_NEEDS); // then verify(tokenRepo).findDailyTotalsBetween(GITHUB_ID, today.minusDays(29), today); @@ -419,7 +425,7 @@ class Query_NeedsGating { .thenReturn(List.of(todayRow)); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.DAILY_1Y)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_1Y)); // then // from=today-364 .. today = 365개, 오름차순으로 0-채움, 조회된 날짜만 값 유지 @@ -447,7 +453,7 @@ class Query_NeedsGating { .thenReturn(List.of(midRow, todayRow)); // when - BadgeResponse res = service.query(USERNAME, + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.DAILY_30D, BadgeDataNeed.DAILY_1Y)); // then @@ -483,7 +489,7 @@ private void stubYearRows(LocalDate today, List rows) { dailyRow(today.minusDays(2).toString(), 30L))); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); // then assertThat(res.streakDays()).isEqualTo(3); @@ -500,7 +506,7 @@ private void stubYearRows(LocalDate today, List rows) { dailyRow(today.minusDays(2).toString(), 30L))); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); // then assertThat(res.streakDays()).isEqualTo(2); @@ -517,7 +523,7 @@ private void stubYearRows(LocalDate today, List rows) { dailyRow(today.minusDays(3).toString(), 40L))); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); // then assertThat(res.streakDays()).isEqualTo(2); @@ -534,7 +540,7 @@ private void stubYearRows(LocalDate today, List rows) { dailyRow(today.minusDays(4).toString(), 50L))); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); // then assertThat(res.streakDays()).isZero(); @@ -553,7 +559,7 @@ private void stubYearRows(LocalDate today, List rows) { dailyRow(today.minusDays(3).toString(), 30L))); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of(BadgeDataNeed.STREAK_DAYS)); // then assertThat(res.streakDays()).isEqualTo(2); @@ -567,7 +573,7 @@ private void stubYearRows(LocalDate today, List rows) { stubYearRows(today, List.of()); // when - BadgeResponse res = service.query(USERNAME, EnumSet.of( + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.of( BadgeDataNeed.DAILY_1Y, BadgeDataNeed.STREAK_DAYS, BadgeDataNeed.YEAR_TOKENS)); // then @@ -577,4 +583,59 @@ private void stubYearRows(LocalDate today, List rows) { assertThat(res.daily1y()).allSatisfy(d -> assertThat(d.total()).isZero()); } } + + @Nested + @DisplayName("query - 고정 스냅샷") + class Query_Snapshot { + + @Test + @DisplayName("스냅샷_요구시_PK조회1회payload반환") + 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); + + // when + BadgeResponse res = service.query(USERNAME, "beta-tester", + EnumSet.of(BadgeDataNeed.BADGE_SNAPSHOT)); + + // then + // 스냅샷만 요구하면 토큰 집계 쿼리는 전혀 실행되지 않는다 + assertThat(res.snapshot()).isEqualTo(payload); + verify(snapshotRepo).findPayload(GITHUB_ID, "beta-tester"); + verifyNoInteractions(tokenRepo); + } + + @Test + @DisplayName("스냅샷_미요구_조회안함") + void 스냅샷_미요구_조회안함() { + // given + // BADGE_SNAPSHOT을 요구하지 않는 테마의 경로 (경계) + when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); + + // when + BadgeResponse res = service.query(USERNAME, BADGE_CODE, EnumSet.noneOf(BadgeDataNeed.class)); + + // then + assertThat(res.snapshot()).isEmpty(); + verifyNoInteractions(snapshotRepo); + } + + @Test + @DisplayName("스냅샷_행없음_빈문자열") + void 스냅샷_행없음_빈문자열() { + // given + // 자격은 있는데 스냅샷 적재가 누락된 사용자 (경계) + when(userRepo.findByUsername(USERNAME)).thenReturn(Optional.of(user())); + when(snapshotRepo.findPayload(GITHUB_ID, "beta-tester")).thenReturn(null); + + // when + BadgeResponse res = service.query(USERNAME, "beta-tester", + EnumSet.of(BadgeDataNeed.BADGE_SNAPSHOT)); + + // then + assertThat(res.snapshot()).isEmpty(); + } + } } 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 0c5141a..bd9d4d9 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 @@ -1,5 +1,6 @@ package com.tokenphage.api.feature.badge.service; +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; @@ -7,6 +8,7 @@ import com.tokenphage.api.feature.badge.dto.response.BadgeSvgResponse; import com.tokenphage.api.feature.badge.exception.BadgeErrorCode; import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +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; import org.junit.jupiter.api.BeforeEach; @@ -84,13 +86,13 @@ void setUp() { * 배지 데이터를 위한 테스트 전용 픽스처를 생성한다 (메모리 전용, 실 DB 미접촉). */ private BadgeResponse sampleData() { - return new BadgeResponse(USERNAME, 12_345L, List.of(), List.of(), 0.5, 0L, 0, List.of()); + return new BadgeResponse(USERNAME, 12_345L, List.of(), List.of(), 0.5, 0L, 0, List.of(), ""); } - /** theme/mode가 이미 정규화된 값(gpu/light)일 때의 정규화 스텁. */ + /** theme이 이미 정규화된 값(gpu)이고 mode가 LIGHT로 확정될 때의 정규화 스텁. */ private void stubNormalizeIdentity() { when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); - when(svgBuilder.normalizeMode(MODE)).thenReturn(MODE); + when(svgBuilder.resolveMode(THEME, MODE)).thenReturn(BadgeMode.LIGHT); } /** 자격이 있는 정상 경로 스텁. 이 스텁이 없으면 Mockito 기본값(null)으로 NPE가 난다. */ @@ -108,10 +110,11 @@ class GrantGate { void 배지조회_자격없음_잠금SVG반환하고조회캐시생략() { // given String lockedSvg = "locked"; - stubNormalizeIdentity(); + when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); when(badgeGrantService.resolveGrant(USERNAME, THEME)) .thenReturn(BadgeGrantResult.deny("Contributor", "PR을 보내주세요")); - when(svgBuilder.isDark(MODE)).thenReturn(false); + // 잠금 렌더는 locked 테마 자신의 지원 집합으로 모드를 다시 정규화한다 + when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); when(lockedBadgeTheme.render("Contributor", "PR을 보내주세요", false)).thenReturn(lockedSvg); // when @@ -121,7 +124,7 @@ class GrantGate { assertThat(result.svg()).isEqualTo(lockedSvg); assertThat(result.granted()).isFalse(); // 자격 거부 경로는 집계 조회·SVG 빌드·캐시 저장을 전부 건너뛴다 - verify(queryService, never()).query(anyString(), any()); + verify(queryService, never()).query(anyString(), anyString(), any()); verify(svgBuilder, never()).build(any(), anyString(), anyString()); verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); } @@ -130,10 +133,10 @@ class GrantGate { @DisplayName("배지조회_자격없음_캐시를조회조차하지않는다") void 배지조회_자격없음_캐시를조회조차하지않는다() { // given - stubNormalizeIdentity(); + when(svgBuilder.normalizeTheme(THEME)).thenReturn(THEME); when(badgeGrantService.resolveGrant(USERNAME, THEME)) .thenReturn(BadgeGrantResult.deny(null, null)); - when(svgBuilder.isDark(MODE)).thenReturn(false); + when(svgBuilder.resolveMode(BadgeCode.LOCKED.getCode(), MODE)).thenReturn(BadgeMode.LIGHT); when(lockedBadgeTheme.render(null, null, false)).thenReturn("locked"); // when @@ -182,7 +185,7 @@ class CacheHit { // then assertThat(result.svg()).isEqualTo(cachedSvg); assertThat(result.granted()).isTrue(); - verify(queryService, never()).query(anyString(), any()); + verify(queryService, never()).query(anyString(), anyString(), any()); verify(svgBuilder, never()).build(any(), anyString(), anyString()); // 캐시 히트 시 set은 호출되지 않아야 한다 verify(valueOps, never()).set(anyString(), anyString(), any(Duration.class)); @@ -204,7 +207,7 @@ class CacheMiss { when(redis.opsForValue()).thenReturn(valueOps); when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, GPU_NEEDS)).thenReturn(data); + when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); // when @@ -213,7 +216,7 @@ class CacheMiss { // then assertThat(result.svg()).isEqualTo(builtSvg); assertThat(result.granted()).isTrue(); - verify(queryService).query(USERNAME, GPU_NEEDS); + verify(queryService).query(USERNAME, THEME, GPU_NEEDS); verify(svgBuilder).build(data, THEME, MODE); } @@ -228,7 +231,7 @@ class CacheMiss { when(redis.opsForValue()).thenReturn(valueOps); when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, GPU_NEEDS)).thenReturn(data); + when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); when(svgBuilder.build(data, THEME, MODE)).thenReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); @@ -250,13 +253,13 @@ class CacheMiss { BadgeResponse data = sampleData(); String builtSvg = "built"; when(svgBuilder.normalizeTheme("ZZZ")).thenReturn("gpu"); - when(svgBuilder.normalizeMode("GARBAGE")).thenReturn("light"); + when(svgBuilder.resolveMode("gpu", "GARBAGE")).thenReturn(BadgeMode.LIGHT); stubGranted(); when(redis.opsForValue()).thenReturn(valueOps); when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); // needsOf는 정규화된 theme("gpu")로 호출된다 when(svgBuilder.needsOf("gpu")).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, GPU_NEEDS)).thenReturn(data); + when(queryService.query(USERNAME, THEME, GPU_NEEDS)).thenReturn(data); when(svgBuilder.build(data, "gpu", "light")).thenReturn(builtSvg); ArgumentCaptor keyCaptor = ArgumentCaptor.forClass(String.class); @@ -287,7 +290,7 @@ class Failure { when(redis.opsForValue()).thenReturn(valueOps); when(valueOps.get(EXPECTED_CACHE_KEY)).thenReturn(null); when(svgBuilder.needsOf(THEME)).thenReturn(GPU_NEEDS); - when(queryService.query(USERNAME, GPU_NEEDS)) + when(queryService.query(USERNAME, THEME, GPU_NEEDS)) .thenThrow(new AppException(BadgeErrorCode.USER_NOT_FOUND)); // when & then diff --git a/src/test/java/com/tokenphage/api/feature/badge/svg/BadgeModeTest.java b/src/test/java/com/tokenphage/api/feature/badge/svg/BadgeModeTest.java new file mode 100644 index 0000000..9ee7390 --- /dev/null +++ b/src/test/java/com/tokenphage/api/feature/badge/svg/BadgeModeTest.java @@ -0,0 +1,130 @@ +package com.tokenphage.api.feature.badge.svg; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.EnumSet; +import java.util.Set; +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; + +/** + * 색상 모드 코드 문자열(URL·캐시 키 계약)과 테마별 정규화를 검증한다. + */ +class BadgeModeTest { + + private static final Set ACCENT_MODES = + EnumSet.of(BadgeMode.CYAN, BadgeMode.GREEN, BadgeMode.PURPLE); + private static final Set LIGHT_DARK_MODES = + EnumSet.of(BadgeMode.LIGHT, BadgeMode.DARK); + + @Nested + @DisplayName("getCode() 검증") + class GetCodeTest { + + @ParameterizedTest(name = "[{index}] {0} → \"{1}\"") + @CsvSource({ + "LIGHT, light", + "DARK, dark", + "CYAN, cyan", + "GREEN, green", + "PURPLE, purple" + }) + @DisplayName("코드조회_각상수_정해진문자열반환") + void 코드조회_각상수_정해진문자열반환(BadgeMode badgeMode, String expected) { + // given + + // when + String result = badgeMode.getCode(); + + // then + assertThat(result).isEqualTo(expected); + } + } + + @Nested + @DisplayName("from() 정규화 검증") + class FromTest { + + @Test + @DisplayName("정규화_지원값_그대로반환") + void 정규화_지원값_그대로반환() { + // given + String raw = "green"; + + // when + BadgeMode result = BadgeMode.from(raw, ACCENT_MODES, BadgeMode.CYAN); + + // then + assertThat(result).isEqualTo(BadgeMode.GREEN); + } + + @Test + @DisplayName("정규화_대소문자혼합_지원값매칭") + void 정규화_대소문자혼합_지원값매칭() { + // given + String raw = "GrEeN"; + + // when + BadgeMode result = BadgeMode.from(raw, ACCENT_MODES, BadgeMode.CYAN); + + // then + assertThat(result).isEqualTo(BadgeMode.GREEN); + } + + @Test + @DisplayName("정규화_미지원값_폴백반환") + void 정규화_미지원값_폴백반환() { + // given + // dark는 존재하는 모드지만 악센트 테마의 지원 집합에는 없다 (경계). + + // when + BadgeMode accentResult = BadgeMode.from("dark", ACCENT_MODES, BadgeMode.CYAN); + BadgeMode cardResult = BadgeMode.from("cyan", LIGHT_DARK_MODES, BadgeMode.LIGHT); + + // then + assertThat(accentResult).isEqualTo(BadgeMode.CYAN); + assertThat(cardResult).isEqualTo(BadgeMode.LIGHT); + } + + @Test + @DisplayName("정규화_쓰레기값_폴백반환") + void 정규화_쓰레기값_폴백반환() { + // given + String raw = "garbage"; + + // when + BadgeMode result = BadgeMode.from(raw, ACCENT_MODES, BadgeMode.CYAN); + + // then + assertThat(result).isEqualTo(BadgeMode.CYAN); + } + + @Test + @DisplayName("정규화_null_폴백반환") + void 정규화_null_폴백반환() { + // given + + // when + BadgeMode result = BadgeMode.from(null, LIGHT_DARK_MODES, BadgeMode.LIGHT); + + // then + assertThat(result).isEqualTo(BadgeMode.LIGHT); + } + + @ParameterizedTest(name = "[{index}] 공백 \"{0}\"은 폴백") + @CsvSource(value = {"''", "' '"}) + @DisplayName("정규화_공백_폴백반환") + void 정규화_공백_폴백반환(String blank) { + // given + + // when + BadgeMode result = BadgeMode.from(blank, ACCENT_MODES, BadgeMode.CYAN); + + // then + assertThat(result).isEqualTo(BadgeMode.CYAN); + } + } +} diff --git a/src/test/java/com/tokenphage/api/feature/badge/svg/SvgBuilderTest.java b/src/test/java/com/tokenphage/api/feature/badge/svg/SvgBuilderTest.java index 62d0f0d..5ded4c4 100644 --- a/src/test/java/com/tokenphage/api/feature/badge/svg/SvgBuilderTest.java +++ b/src/test/java/com/tokenphage/api/feature/badge/svg/SvgBuilderTest.java @@ -3,9 +3,11 @@ import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.dto.response.DailyCountResponse; import com.tokenphage.api.feature.badge.dto.response.ModelCountResponse; +import com.tokenphage.api.feature.badge.svg.theme.betatester.BetaTesterBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.card.claude.CardClaudeBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.card.gpu.CardGpuBadgeTheme; import com.tokenphage.api.feature.badge.svg.theme.grass.claude.GrassClaudeBadgeTheme; +import com.tokenphage.api.feature.badge.svg.theme.locked.LockedBadgeTheme; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -25,10 +27,11 @@ class SvgBuilderTest { @BeforeEach void setUp() { - // gpu + claude + grass-claude 테마를 등록 (실제 @Component 빈 주입과 동일한 구성) - // grass-claude 미등록 시 normalizeTheme이 gpu로 폴백해 신규 케이스가 공허 통과하므로 반드시 포함한다. + // 등록 테마 5종 전부 (실제 @Component 빈 주입과 동일한 구성) + // 미등록 시 normalizeTheme·resolveMode가 gpu로 폴백해 신규 케이스가 공허 통과하므로 반드시 포함한다. svgBuilder = new SvgBuilder(List.of( - new CardGpuBadgeTheme(), new CardClaudeBadgeTheme(), new GrassClaudeBadgeTheme())); + new CardGpuBadgeTheme(), new CardClaudeBadgeTheme(), new GrassClaudeBadgeTheme(), + new BetaTesterBadgeTheme(), new LockedBadgeTheme())); } private BadgeResponse sampleData() { @@ -46,7 +49,7 @@ private BadgeResponse sampleData() { new ModelCountResponse("claude-sonnet-4-6", 1_200_000), new ModelCountResponse("claude-opus-4-7", 450_000) ); - return new BadgeResponse("leeyoungseok", 15_430_000L, daily30d, topModels, 0.87, 0L, 0, List.of()); + return new BadgeResponse("leeyoungseok", 15_430_000L, daily30d, topModels, 0.87, 0L, 0, List.of(), ""); } private static int count(String text, String token) { @@ -69,7 +72,9 @@ class BadgeLink { @ParameterizedTest(name = "theme={0}, mode={1} → 저장소 링크 앵커 1개") @DisplayName("모든 테마·모드의 뱃지는 저장소 URL 앵커로 정확히 한 번 감싸진다") @CsvSource({ "gpu, light", "gpu, dark", "claude, light", "claude, dark", - "grass-claude, light", "grass-claude, dark" }) + "grass-claude, light", "grass-claude, dark", + "beta-tester, cyan", "beta-tester, green", "beta-tester, purple", + "locked, light" }) void badge_모든테마모드_저장소앵커로감쌈(String theme, String mode) { // given / when String svg = svgBuilder.build(sampleData(), theme, mode); @@ -111,7 +116,7 @@ void dispatch_modeUppercase_darkApplied() { } } - @Nested @DisplayName("normalizeTheme / normalizeMode (캐시 키 정규화)") + @Nested @DisplayName("normalizeTheme / resolveMode (캐시 키 정규화)") class Normalize { @Test @DisplayName("등록된 테마 → 소문자 식별자 그대로 반환") @@ -139,14 +144,25 @@ class Normalize { assertThat(svgBuilder.normalizeTheme(null)).isEqualTo("gpu"); } - @ParameterizedTest(name = "mode=\"{0}\" → \"{1}\"") - @DisplayName("dark는 대소문자 무관하게 dark, 그 외/null은 light") + @ParameterizedTest(name = "theme={0}, mode=\"{1}\" → {2}") + @DisplayName("resolveMode는 테마별 지원 집합으로 정규화한다") @CsvSource(value = { - "dark, dark", "DARK, dark", "Dark, dark", - "light, light", "garbage, light", "'', light", "NULL, light" + "gpu, dark, DARK", "gpu, DARK, DARK", "gpu, Dark, DARK", + "gpu, light, LIGHT", "gpu, garbage, LIGHT", "gpu, '', LIGHT", "gpu, NULL, LIGHT", + "gpu, cyan, LIGHT", + "beta-tester, cyan, CYAN", "beta-tester, green, GREEN", "beta-tester, PURPLE, PURPLE", + "beta-tester, light, CYAN", "beta-tester, dark, CYAN", "beta-tester, NULL, CYAN" }, nullValues = "NULL") - void normalizeMode_정규화(String input, String expected) { - assertThat(svgBuilder.normalizeMode(input)).isEqualTo(expected); + void resolveMode_테마별정규화(String theme, String input, BadgeMode expected) { + // 카드 테마는 악센트 3색 미지원 → LIGHT, beta-tester는 light/dark 미지원 → CYAN 폴백 + assertThat(svgBuilder.resolveMode(theme, input)).isEqualTo(expected); + } + + @Test @DisplayName("resolveMode_미등록테마_기본테마기준정규화") + void resolveMode_미등록테마_기본테마기준정규화() { + // 미등록 theme은 normalizeTheme과 같은 폴백(gpu) 기준으로 모드를 정한다 + assertThat(svgBuilder.resolveMode("zzz999", "dark")).isEqualTo(BadgeMode.DARK); + assertThat(svgBuilder.resolveMode("zzz999", "green")).isEqualTo(BadgeMode.LIGHT); } } diff --git a/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeThemeTest.java b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeThemeTest.java new file mode 100644 index 0000000..0087ebf --- /dev/null +++ b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterBadgeThemeTest.java @@ -0,0 +1,522 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.tokenphage.api.domain.badge.BadgeCode; +import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; +import com.tokenphage.api.feature.badge.svg.BadgeDataNeed; +import com.tokenphage.api.feature.badge.svg.BadgeMode; +import com.tokenphage.api.feature.badge.svg.SvgText; +import java.util.List; +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; + +/** + * 터미널 창 배지의 테마 계약·SVG 구조·팔레트 3종·스냅샷 표기를 검증한다. + *

+ * 좌표·표기는 프로토타입 실측값으로 고정한다. 스냅샷 문자열은 변환 없이 그대로 나가야 한다. + */ +class BetaTesterBadgeThemeTest { + + /** 프로토타입 기준 사용자명(7자) — 커서 x=176 실측의 전제. */ + private static final String PROTO_NAME = "lys9908"; + + private static final String FULL_JSON = snapshotJson(10, "2026.07 – 2026.08", "324T", true, true); + + private final BetaTesterBadgeTheme theme = new BetaTesterBadgeTheme(); + + private static String snapshotJson(int signupRank, String period, String tokensAdded, + boolean isClaudeUse, boolean isGptUse) { + return """ + {"signupRank":%d,"period":"%s","syncsRun":"128","badgeServed":"4.2K", + "tokensAdded":"%s","isClaudeUse":%s,"isGptUse":%s}""" + .formatted(signupRank, period, tokensAdded, isClaudeUse, isGptUse); + } + + private static BadgeResponse data(String username, String snapshotJson) { + return new BadgeResponse(username, 0L, List.of(), List.of(), 0.0, 0L, 0, List.of(), snapshotJson); + } + + private static int countOccurrences(String text, String token) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(token, idx)) != -1) { + count++; + idx += token.length(); + } + return count; + } + + @Nested + @DisplayName("테마 계약") + class ThemeContract { + + @Test + @DisplayName("테마이름_조회_BadgeCode상수와일치") + void 테마이름_조회_BadgeCode상수와일치() { + // given + + // when + String result = theme.name(); + + // then + assertThat(result).isEqualTo(BadgeCode.BETA_TESTER.getCode()); + assertThat(result).isEqualTo("beta-tester"); + } + + @Test + @DisplayName("필요한데이터_조회_스냅샷하나") + void 필요한데이터_조회_스냅샷하나() { + // given + // 표시 항목 전부가 고정 스냅샷 한 행에서 온다. 라이브 집계 needs가 섞이면 안 된다. + + // when + // then + assertThat(theme.needs()).containsExactly(BadgeDataNeed.BADGE_SNAPSHOT); + } + + @Test + @DisplayName("지원모드_조회_악센트3종") + void 지원모드_조회_악센트3종() { + // given + + // when + // then + assertThat(theme.supportedModes()).containsExactlyInAnyOrder( + BadgeMode.CYAN, BadgeMode.GREEN, BadgeMode.PURPLE); + } + + @Test + @DisplayName("기본모드_조회_cyan") + void 기본모드_조회_cyan() { + // given + + // when + // then + assertThat(theme.defaultMode()).isEqualTo(BadgeMode.CYAN); + } + } + + @Nested + @DisplayName("SVG 구조") + class SvgStructure { + + @Test + @DisplayName("기본구조_루트속성_표시크기410x161와앵커랩") + void 기본구조_루트속성_표시크기410x161와앵커랩() { + // given + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("width=\"410\"").contains("height=\"161\"") + .contains("viewBox=\"0 0 310 122\"") + .contains("xmlns:xlink=\"http://www.w3.org/1999/xlink\"") + .contains("role=\"img\"") + .contains("aria-label=\"Tokenphage beta tester lys9908\"") + .contains(""); + assertThat(countOccurrences(svg, " 임베드에서는 외부 CSS·스크립트가 차단된다. + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .doesNotContain(" allAccents = List.of("#64D2FF", "#64DC82", "#D08BF5"); + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), mode); + + // then + assertThat(svg).contains(accent); + allAccents.stream() + .filter(hex -> !hex.equals(accent)) + .forEach(other -> assertThat(svg).doesNotContain(other)); + } + + @Test + @DisplayName("글로우_모드별_팔레트필터적용") + void 글로우_모드별_팔레트필터적용() { + // given + // 세 모드는 같은 이중 그림자 수치를 쓰고 글로우 색만 다르다. + + // when + String cyan = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + String green = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.GREEN); + + // then + assertThat(cyan).contains(BetaTesterColors.CYAN.mascotGlow()); + assertThat(countOccurrences(cyan, "drop-shadow(")).isEqualTo(2); + assertThat(green).contains(BetaTesterColors.GREEN.mascotGlow()); + assertThat(countOccurrences(green, "drop-shadow(")).isEqualTo(2); + } + + @Test + @DisplayName("모드접미사_defs와클래스_모드별유니크") + void 모드접미사_defs와클래스_모드별유니크() { + // given + // 한 페이지에 두 모드를 함께 임베드해도 id·클래스가 충돌하면 안 된다. + + // when + String cyan = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + String green = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.GREEN); + + // then + assertThat(cyan).contains("tp-bt-clip-cyan").contains("tp-bt-scanline-cyan"); + assertThat(green) + .contains("tp-bt-clip-green").contains("tp-bt-scanline-green") + .doesNotContain("tp-blink-cyan"); + } + + @Test + @DisplayName("비지원모드_직접전달_cyan으로렌더") + void 비지원모드_직접전달_cyan으로렌더() { + // given + // 디스패처를 거치지 않은 직접 호출 경로 (경계). + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.DARK); + + // then + assertThat(svg).contains("#64D2FF").contains("tp-blink-cyan"); + } + } + + @Nested + @DisplayName("유저명·커서") + class Username { + + @Test + @DisplayName("유저명_7자_커서실측x176") + void 유저명_7자_커서실측x176() { + // given + // 95 + (@ 포함 8글자) × 10.125 = 176 — 프로토타입 실측과 일치해야 한다. + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("@lys9908") + .contains("x=\"176\" y=\"42\" width=\"5\" height=\"14\""); + } + + @Test + @DisplayName("유저명_19자_말줄임없이경계커서") + void 유저명_19자_말줄임없이경계커서() { + // given + // 표시 상한 19자 직전값 — 커서 x=95+20×10.125=297.5로 우측 경계(298) 안 (경계). + String name = "a".repeat(19); + + // when + String svg = theme.build(data(name, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("@" + name + "") + .contains("x=\"297.5\" y=\"42\""); + } + + @Test + @DisplayName("유저명_20자_말줄임과경계커서") + void 유저명_20자_말줄임과경계커서() { + // given + // 상한 직후값 — 18자 + 말줄임표로 접는다 (경계). + String name = "a".repeat(20); + + // when + String svg = theme.build(data(name, FULL_JSON), BadgeMode.CYAN); + + // then + // aria-label에는 원본 전체 이름이 남으므로 부재 단언은 표시 텍스트(@ tspan 뒤)로 좁힌다 + assertThat(svg) + .contains("@" + "a".repeat(18) + "…") + .doesNotContain("@" + "a".repeat(19)) + .contains("x=\"297.5\" y=\"42\""); + } + + @Test + @DisplayName("유저명_40자_우측경계이내") + void 유저명_40자_우측경계이내() { + // given + // users.username 최대 길이(VARCHAR 40)에서도 커서가 본문 밖으로 나가면 안 된다. + String name = "b".repeat(40); + + // when + String svg = theme.build(data(name, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("b".repeat(18) + "…") + .contains("x=\"297.5\" y=\"42\""); + } + + @Test + @DisplayName("유저명_특수문자_이스케이프") + void 유저명_특수문자_이스케이프() { + // given + String name = "u&x"; + + // when + String svg = theme.build(data(name, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg).contains("u<s>&x").doesNotContain(""); + } + } + + @Nested + @DisplayName("스냅샷 표기") + class SnapshotDisplay { + + @Test + @DisplayName("통계3칸_문자열그대로_변환없음") + void 통계3칸_문자열그대로_변환없음() { + // given + // 표시 포맷은 적재 시점에 고정됐다. formatTokens를 거치면 324T가 324.0T로 바뀐다. + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains(">128") + .contains(">4.2K") + .contains(">324T") + .doesNotContain("324.0T"); + } + + @Test + @DisplayName("통계라벨_3종_고정문구") + void 통계라벨_3종_고정문구() { + // given + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("CONTRIBUTED DURING BETA") + .contains("SYNCS RUN") + .contains("BADGE SERVED") + .contains("TOKENS ADDED"); + } + + @Test + @DisplayName("통계문자열_특수문자_이스케이프") + void 통계문자열_특수문자_이스케이프() { + // given + // 스냅샷은 DB에서 온 자유 문자열이라 주입 표면이다. + String json = snapshotJson(10, "2026.07 – 2026.08", "1&2", true, true); + + // when + String svg = theme.build(data(PROTO_NAME, json), BadgeMode.CYAN); + + // then + assertThat(svg).contains("<b>1&2</b>").doesNotContain(""); + } + + @Test + @DisplayName("가입순번_양수_표시") + void 가입순번_양수_표시() { + // given + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg).contains(">#10").contains("text-anchor=\"middle\""); + } + + @Test + @DisplayName("가입순번_0_미표시") + void 가입순번_0_미표시() { + // given + // 없는 값을 0위라고 주장하지 않는다 (경계). + String json = snapshotJson(0, "2026.07 – 2026.08", "324T", true, true); + + // when + String svg = theme.build(data(PROTO_NAME, json), BadgeMode.CYAN); + + // then + assertThat(svg).doesNotContain(">#"); + } + + @Test + @DisplayName("기간_값있음_배너옆표시") + void 기간_값있음_배너옆표시() { + // given + // 우측 끝 정렬에서 배너 옆으로 옮겼다 — 배너와 같은 베이스라인(y=49)에 이어 붙는다. + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains(">2026.07 – 2026.08") + .contains("x=\"169\" y=\"37\""); + } + + @Test + @DisplayName("기간_빈값_미표시") + void 기간_빈값_미표시() { + // given + String json = snapshotJson(10, "", "324T", true, true); + + // when + String svg = theme.build(data(PROTO_NAME, json), BadgeMode.CYAN); + + // then + // letter-spacing=".4"는 기간 텍스트에만 쓰인다 (타이틀바 .3 / 라벨 .6 / 섹션 .9). + assertThat(svg).doesNotContain("letter-spacing=\".4\""); + } + + @Test + @DisplayName("통계3칸_배치_열간격확보") + void 통계3칸_배치_열간격확보() { + // given + // 라벨이 7px로 커져 프로토타입 간격(95/139/194)으로는 열이 겹친다. + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then: BADGE SERVED(12자 × 4.8 = 57.6)가 다음 열 224를 침범하지 않는 x + assertThat(svg) + .contains("x=\"95\" y=\"93\"") + .contains("x=\"152\" y=\"93\"") + .contains("x=\"224\" y=\"93\""); + } + + @Test + @DisplayName("셸프롬프트_고정문구_타이핑클래스") + void 셸프롬프트_고정문구_타이핑클래스() { + // given + + // when + String svg = theme.build(data(PROTO_NAME, FULL_JSON), BadgeMode.CYAN); + + // then + assertThat(svg) + .contains("echo \"Thanks for shaping the beta.\"") + .contains("tp-type-cyan"); + } + } + + @Nested + @DisplayName("사용 모델 로고") + class ProviderMarks { + + @ParameterizedTest(name = "[{index}] claude={0}, gpt={1} → claude로고 {2}개, openai로고 {3}개") + @CsvSource({ + "true, false, 1, 0", + "false, true, 0, 1", + "true, true, 1, 1", + "false, false, 0, 0" + }) + @DisplayName("로고_조합4가지_해당로고만표시") + void 로고_조합4가지_해당로고만표시(boolean claude, boolean gpt, int claudeCount, int openaiCount) { + // given + String json = snapshotJson(10, "2026.07 – 2026.08", "324T", claude, gpt); + + // when + String svg = theme.build(data(PROTO_NAME, json), BadgeMode.CYAN); + + // then + assertThat(countOccurrences(svg, "M4.709")).isEqualTo(claudeCount); + assertThat(countOccurrences(svg, "M22.2819")).isEqualTo(openaiCount); + } + } + + @Nested + @DisplayName("스냅샷 결손") + class MissingSnapshot { + + @Test + @DisplayName("스냅샷빈문자열_예외없이_대시와미표시렌더") + void 스냅샷빈문자열_예외없이_대시와미표시렌더() { + // given + // 자격은 있는데 스냅샷 적재가 누락된 사용자 (경계). + BadgeResponse missing = data("newbie", ""); + + // when + // then + assertThatCode(() -> theme.build(missing, BadgeMode.CYAN)).doesNotThrowAnyException(); + + String svg = theme.build(missing, BadgeMode.CYAN); + assertThat(countOccurrences(svg, ">-")).isEqualTo(3); + assertThat(svg) + .doesNotContain(">#") + .doesNotContain("letter-spacing=\".4\"") + .contains("@newbie"); + } + } +} diff --git a/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascotTest.java b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascotTest.java new file mode 100644 index 0000000..7425ae5 --- /dev/null +++ b/src/test/java/com/tokenphage/api/feature/badge/svg/theme/betatester/BetaTesterMascotTest.java @@ -0,0 +1,194 @@ +package com.tokenphage.api.feature.badge.svg.theme.betatester; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.tokenphage.api.feature.badge.svg.BadgeMode; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 픽셀 파지 마스코트의 무결성(개수·대칭·배치)과 모드별 글로우를 검증한다. + *

+ * rect 블록은 프로토타입에서 추출한 문자열 상수라 컴파일러가 손상을 못 잡는다. 픽셀 격자로 대조한다. + */ +class BetaTesterMascotTest { + + /** 프로토타입 실측 rect 개수. */ + private static final int PIXEL_RECT_COUNT = 42; + + /** 픽셀 격자 폭·높이 (프로토타입 실측 16×14). */ + private static final int GRID_W = 16; + private static final int GRID_H = 14; + + private static final Pattern RECT = Pattern.compile( + ""); + + private static int countOccurrences(String text, String token) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(token, idx)) != -1) { + count++; + idx += token.length(); + } + return count; + } + + /** + * 렌더된 rect들을 픽셀 격자에 칠한다. 뒤에 나오는 rect가 앞을 덮는다(음영 픽셀 처리와 동일). + */ + private static String[][] paintGrid(String svg) { + String[][] grid = new String[GRID_H][GRID_W]; + Matcher m = RECT.matcher(svg); + while (m.find()) { + int x = Integer.parseInt(m.group(1)); + int y = Integer.parseInt(m.group(2)); + int w = Integer.parseInt(m.group(3)); + int h = Integer.parseInt(m.group(4)); + for (int dy = 0; dy < h; dy++) { + for (int dx = 0; dx < w; dx++) { + grid[y + dy][x + dx] = m.group(5); + } + } + } + return grid; + } + + @Nested + @DisplayName("픽셀 무결성") + class PixelIntegrity { + + @Test + @DisplayName("마스코트_렌더_rect42개") + void 마스코트_렌더_rect42개() { + // given + + // when + String svg = BetaTesterMascot.render(BadgeMode.CYAN); + + // then + assertThat(countOccurrences(svg, " + * 로고 표시는 스냅샷 boolean 두 개로만 결정된다. path d 시작점 문자열로 어느 로고인지 구분한다. + */ +class BetaTesterProviderMarkTest { + + /** Claude 로고 path d 시작점 (프로토타입 실측). */ + private static final String CLAUDE_MARK_HEAD = "M4.709"; + + /** OpenAI 로고 path d 시작점 (프로토타입 실측). */ + private static final String OPENAI_MARK_HEAD = "M22.2819"; + + private static int countOccurrences(String text, String token) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(token, idx)) != -1) { + count++; + idx += token.length(); + } + return count; + } + + @Nested + @DisplayName("로고 조합") + class Combination { + + @Test + @DisplayName("로고_claude만_클로드로고만") + void 로고_claude만_클로드로고만() { + // given + + // when + String svg = BetaTesterProviderMark.render(true, false, BetaTesterColors.CYAN); + + // then + assertThat(svg).contains(CLAUDE_MARK_HEAD).doesNotContain(OPENAI_MARK_HEAD); + } + + @Test + @DisplayName("로고_gpt만_오픈AI로고만") + void 로고_gpt만_오픈AI로고만() { + // given + + // when + String svg = BetaTesterProviderMark.render(false, true, BetaTesterColors.CYAN); + + // then + assertThat(svg).contains(OPENAI_MARK_HEAD).doesNotContain(CLAUDE_MARK_HEAD); + } + + @Test + @DisplayName("로고_둘다_로고2개") + void 로고_둘다_로고2개() { + // given + + // when + String svg = BetaTesterProviderMark.render(true, true, BetaTesterColors.CYAN); + + // then + assertThat(countOccurrences(svg, " + * JSON 키는 DB에 쌓인 문자열 계약이라 컴파일러가 못 잡는다. 키 이름·결손 처리 전부를 값으로 고정한다. + */ +class BetaTesterSnapshotTest { + + private static final String FULL_JSON = """ + {"signupRank":10,"period":"2026.07 – 2026.08","syncsRun":"128", + "badgeServed":"4.2K","tokensAdded":"324T","isClaudeUse":true,"isGptUse":true} + """; + + @Nested + @DisplayName("parse() 정상 경로") + class ParseHappyPath { + + @Test + @DisplayName("스냅샷파싱_정상JSON_필드매핑") + void 스냅샷파싱_정상JSON_필드매핑() { + // given + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(FULL_JSON); + + // then + assertThat(result.signupRank()).isEqualTo(10); + assertThat(result.period()).isEqualTo("2026.07 – 2026.08"); + assertThat(result.syncsRun()).isEqualTo("128"); + assertThat(result.badgeServed()).isEqualTo("4.2K"); + assertThat(result.tokensAdded()).isEqualTo("324T"); + assertThat(result.isClaudeUse()).isTrue(); + assertThat(result.isGptUse()).isTrue(); + } + + @Test + @DisplayName("스냅샷파싱_통계문자열_그대로유지") + void 스냅샷파싱_통계문자열_그대로유지() { + // given + // 표시 포맷은 적재 시점에 고정된다. 파싱이 단위를 재변환하면 안 된다. + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(FULL_JSON); + + // then + assertThat(result.tokensAdded()).isEqualTo("324T").isNotEqualTo("324.0T"); + } + + @Test + @DisplayName("스냅샷파싱_모르는키포함_무시하고매핑") + void 스냅샷파싱_모르는키포함_무시하고매핑() { + // given + // 스키마가 앞으로 늘어도 기존 렌더가 깨지면 안 된다. + String json = """ + {"signupRank":3,"futureKey":"whatever","syncsRun":"7"} + """; + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(json); + + // then + assertThat(result.signupRank()).isEqualTo(3); + assertThat(result.syncsRun()).isEqualTo("7"); + } + } + + @Nested + @DisplayName("parse() 결손·실패 처리") + class ParseDegradedPath { + + @Test + @DisplayName("스냅샷파싱_빈문자열_빈객체") + void 스냅샷파싱_빈문자열_빈객체() { + // given + // 자격은 있는데 스냅샷 행이 없으면 조회 계층이 빈 문자열을 싣는다 (경계). + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(""); + + // then + assertThat(result).isEqualTo(BetaTesterSnapshot.empty()); + } + + @Test + @DisplayName("스냅샷파싱_null_빈객체") + void 스냅샷파싱_null_빈객체() { + // given + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(null); + + // then + assertThat(result).isEqualTo(BetaTesterSnapshot.empty()); + } + + @Test + @DisplayName("스냅샷파싱_깨진JSON_빈객체와예외없음") + void 스냅샷파싱_깨진JSON_빈객체와예외없음() { + // given + // 파싱 실패로 예외가 나가면 배지 이미지 자체가 깨진다. + String broken = "{signupRank: not-json"; + + // when + // then + assertThatCode(() -> BetaTesterSnapshot.parse(broken)).doesNotThrowAnyException(); + assertThat(BetaTesterSnapshot.parse(broken)).isEqualTo(BetaTesterSnapshot.empty()); + } + + @Test + @DisplayName("스냅샷파싱_boolean키누락_false") + void 스냅샷파싱_boolean키누락_false() { + // given + String json = """ + {"signupRank":5,"period":"2026.07 – 2026.08","syncsRun":"1","badgeServed":"2","tokensAdded":"3"} + """; + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(json); + + // then + assertThat(result.isClaudeUse()).isFalse(); + assertThat(result.isGptUse()).isFalse(); + } + + @Test + @DisplayName("스냅샷파싱_통계null_대시로정규화") + void 스냅샷파싱_통계null_대시로정규화() { + // given + // 테마가 null 문자열을 만나지 않도록 파싱 단계에서 표시 가능한 값으로 접는다 (경계). + String json = """ + {"signupRank":5,"syncsRun":null,"badgeServed":"","tokensAdded":null} + """; + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.parse(json); + + // then + assertThat(result.syncsRun()).isEqualTo("-"); + assertThat(result.badgeServed()).isEqualTo("-"); + assertThat(result.tokensAdded()).isEqualTo("-"); + assertThat(result.period()).isEmpty(); + } + } + + @Nested + @DisplayName("empty() 검증") + class EmptyTest { + + @Test + @DisplayName("빈스냅샷_생성_표시가능한기본값") + void 빈스냅샷_생성_표시가능한기본값() { + // given + + // when + BetaTesterSnapshot result = BetaTesterSnapshot.empty(); + + // then + // 순번·기간은 미표시 판정값(0/""), 통계 3칸은 자리 표시 "-" + assertThat(result.signupRank()).isZero(); + assertThat(result.period()).isEmpty(); + assertThat(result.syncsRun()).isEqualTo("-"); + assertThat(result.badgeServed()).isEqualTo("-"); + assertThat(result.tokensAdded()).isEqualTo("-"); + assertThat(result.isClaudeUse()).isFalse(); + assertThat(result.isGptUse()).isFalse(); + } + } +} 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 5f5b477..7f4a065 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 @@ -3,6 +3,7 @@ import com.tokenphage.api.feature.badge.dto.response.BadgeResponse; import com.tokenphage.api.feature.badge.dto.response.DailyCountResponse; import com.tokenphage.api.feature.badge.dto.response.ModelCountResponse; +import com.tokenphage.api.feature.badge.svg.BadgeMode; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -24,7 +25,7 @@ private BadgeResponse dataWith(long totalTokens) { List models = List.of( new ModelCountResponse("claude-sonnet-4-6", 1_200_000), new ModelCountResponse("claude-opus-4-7", 450_000)); - return new BadgeResponse("leeyoungseok", totalTokens, daily30d, models, 0.87, 0L, 0, List.of()); + return new BadgeResponse("leeyoungseok", totalTokens, daily30d, models, 0.87, 0L, 0, List.of(), ""); } private static int count(String text, String token) { @@ -53,7 +54,7 @@ class Layout { @Test @DisplayName("배지 골격·30 히트바·Top5·30일 레이블 포함") void reusesBaseLayout() { - String svg = theme.build(dataWith(5_000_000L), false); + String svg = theme.build(dataWith(5_000_000L), BadgeMode.LIGHT); assertThat(svg) .contains("width=\"540\"").contains("") .contains("TOKEN 누적").contains("Top 5 Models").contains("최근 30일") @@ -65,7 +66,7 @@ void reusesBaseLayout() { @DisplayName("마스코트 픽셀 group에 crispEdges가 적용된다(안티앨리어싱 seam 방지)") void mascotUsesCrispEdges() { // 1×1 픽셀 rect들이 안티앨리어싱으로 가는 틈(격자무늬)을 만들지 않도록 crispEdges가 걸려야 한다. - String svg = theme.build(dataWith(50_000L), false); + String svg = theme.build(dataWith(50_000L), BadgeMode.LIGHT); assertThat(svg).contains("shape-rendering=\"crispEdges\""); } } @@ -77,7 +78,7 @@ class Levels { @Test @DisplayName("Lv1(5만): 글로우 없음, chew 4.80s, 팝업 없음, 스파클 3") void lv1() { - String svg = theme.build(dataWith(50_000L), false); + String svg = theme.build(dataWith(50_000L), BadgeMode.LIGHT); assertThat(svg).doesNotContain("") .contains("width=\"540\"").contains("height=\"210\""); } @Test @DisplayName("다크 모드 — SVG 정상 생성") void darkMode_svgGenerated() { - assertThat(gpuTheme.build(sampleData(), true)).contains(""); + assertThat(gpuTheme.build(sampleData(), BadgeMode.DARK)).contains(""); } @Test @DisplayName("라이트 모드 — 흰색 배경 (#ffffff)") void lightMode_whiteBackground() { - assertThat(gpuTheme.build(sampleData(), false)).contains("#ffffff"); + assertThat(gpuTheme.build(sampleData(), BadgeMode.LIGHT)).contains("#ffffff"); } @Test @DisplayName("다크 모드 — 다크 배경 (#0f172a)") void darkMode_darkBackground() { - assertThat(gpuTheme.build(sampleData(), true)).contains("#0f172a"); + assertThat(gpuTheme.build(sampleData(), BadgeMode.DARK)).contains("#0f172a"); } @Test @DisplayName("aria-label에 사용자명 포함 (접근성)") void svg_ariaLabelContainsUsername() { - assertThat(gpuTheme.build(sampleData(), false)) + assertThat(gpuTheme.build(sampleData(), BadgeMode.LIGHT)) .contains("aria-label=\"TokenBadge leeyoungseok\""); } @Test @DisplayName("가로/세로 구분선 포함") void svg_containsDividers() { - String svg = gpuTheme.build(sampleData(), false); + String svg = gpuTheme.build(sampleData(), BadgeMode.LIGHT); assertThat(svg).contains("x1=\"20\" y1=\"76\"") .contains("x1=\"280\"").contains("x2=\"280\""); } @@ -101,20 +102,20 @@ class UserInfo { @Test @DisplayName("사용자명이 @ 접두사와 함께 SVG에 포함됨") void username_renderedInSvg() { - assertThat(gpuTheme.build(sampleData(), false)).contains("@leeyoungseok"); + assertThat(gpuTheme.build(sampleData(), BadgeMode.LIGHT)).contains("@leeyoungseok"); } @Test @DisplayName("HTML 특수문자 사용자명 → 이스케이프 (XSS 방어)") void username_htmlEscaped() { - BadgeResponse data = new BadgeResponse("", 0L, List.of(), List.of(), 0.0, 0L, 0, List.of()); - String svg = gpuTheme.build(data, false); + BadgeResponse data = new BadgeResponse("", 0L, List.of(), List.of(), 0.0, 0L, 0, List.of(), ""); + String svg = gpuTheme.build(data, BadgeMode.LIGHT); assertThat(svg).doesNotContain("

+ * 컴파일러가 못 잡는 계약이 대상이다 — 복합 PK WHERE 절, payload::text 캐스팅, jsonb 왕복, + * 축약 표시 문자열. 컨테이너 DB 를 공유하고 롤백이 없어 AfterEach 에서 FK 역순으로 정리한다. + */ +@Tag("integration") +@SpringBootTest +@DisplayName("BadgeSnapshotRepository native 쿼리와 badge_compact_number") +class BadgeSnapshotRepositoryIntegrationTest extends ContainerSupport { + + /** V7 이 시드하는 자격 필요 배지 코드. 시드 행이므로 절대 삭제하지 않는다. */ + private static final String BADGE_CODE = "beta-tester"; + /** V5 가 시드하는 공개 배지 코드 — 복합키의 badge_code 축 격리 검증용. */ + private static final String OTHER_BADGE_CODE = "gpu"; + + // 싱글턴 컨테이너 DB를 통합테스트 클래스들이 공유하므로 다른 클래스와 겹치지 않는 값을 쓴다 + // (Sync 99901, Reset 99902, BadgeGrant 99903·99904, Auth 99905). + private static final long GITHUB_ID = 99906L; + private static final String USERNAME = "cli_snapshot_test"; + private static final long OTHER_GITHUB_ID = 99907L; + private static final String OTHER_USERNAME = "cli_snapshot_other"; + + /** readTree 전용이라 모듈 설정이 필요 없어 스프링 빈 대신 정적으로 둔다. */ + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** + * V7 적재와 같은 7키 스키마. period 의 en dash(–)로 비ASCII 왕복까지 검증한다. + * jsonb 가 키 순서·공백을 정규화하므로 일부러 비정규 형태(줄바꿈·들여쓰기)로 둔다. + */ + private static final String PAYLOAD_JSON = """ + {"signupRank": 7, + "period": "2026.07 – 2026.08", + "syncsRun": "42", + "badgeServed": "1.2K", + "tokensAdded": "324T", + "isClaudeUse": true, + "isGptUse": false} + """; + + @Autowired + private BadgeSnapshotRepository repository; + + @Autowired + private JdbcTemplate jdbc; + + @AfterEach + void cleanUp() { + // FK 역순: badge_snapshot → users. badge_catalog 는 시드(V5 gpu, V7 beta-tester)라 건드리지 않는다. + jdbc.update("DELETE FROM badge_snapshot WHERE github_id IN (?, ?)", GITHUB_ID, OTHER_GITHUB_ID); + jdbc.update("DELETE FROM users WHERE github_id IN (?, ?)", GITHUB_ID, OTHER_GITHUB_ID); + } + + private void insertUser(long githubId, String username) { + jdbc.update("INSERT INTO users (github_id, username) VALUES (?, ?)", githubId, username); + } + + private void insertSnapshot(long githubId, String badgeCode, String payloadJson) { + // ?::jsonb 캐스팅 필수 — 없으면 character varying 타입 에러로 INSERT 자체가 실패한다. + jdbc.update(""" + INSERT INTO badge_snapshot (github_id, badge_code, payload) + VALUES (?, ?, ?::jsonb) + """, githubId, badgeCode, payloadJson); + } + + /** 격리 테스트용 최소 payload. findPayload 는 스키마를 검증하지 않으므로 marker 하나면 충분하다. */ + private static String markerPayload(int rank) { + return "{\"signupRank\": %d}".formatted(rank); + } + + @Nested + @DisplayName("findPayload() - 조회 성공과 jsonb 왕복") + class RoundTrip { + + @Test + @DisplayName("저장한 payload가 값·타입 그대로 조회된다") + void 페이로드조회_스냅샷존재_JSON왕복일치() throws Exception { + // given: V7 스키마와 동일한 7키 payload + insertUser(GITHUB_ID, USERNAME); + insertSnapshot(GITHUB_ID, BADGE_CODE, PAYLOAD_JSON); + + // when + String found = repository.findPayload(GITHUB_ID, BADGE_CODE); + + // then: jsonb는 저장 시 키 순서·공백을 정규화하므로 원문 문자열 비교 대신 트리 동등성으로 검증한다 + assertThat(found).isNotNull(); + assertThat(MAPPER.readTree(found)).isEqualTo(MAPPER.readTree(PAYLOAD_JSON)); + } + } + + @Nested + @DisplayName("findPayload() - 행 없음과 복합키 격리") + class MissingAndIsolation { + + @Test + @DisplayName("스냅샷 행이 없으면 null이다") + void 페이로드조회_스냅샷없음_null반환() { + // given: 사용자만 있고 스냅샷은 없다 — 자격은 있는데 적재가 누락된 경로. + // V7의 스냅샷 적재는 컨테이너 기동 직후(users 0행)에 끝났으므로 실행 순서와 무관하다. + insertUser(GITHUB_ID, USERNAME); + + // when + String found = repository.findPayload(GITHUB_ID, BADGE_CODE); + + // then: WHERE 복합 PK가 매칭되지 않아 행이 없다 + assertThat(found).isNull(); + } + + @Test + @DisplayName("같은 사용자의 다른 배지 스냅샷과 섞이지 않는다") + void 페이로드조회_같은사용자_다른배지코드_해당배지행만반환() throws Exception { + // given: 한 사용자가 배지 코드 2개의 스냅샷을 보유한다 + insertUser(GITHUB_ID, USERNAME); + insertSnapshot(GITHUB_ID, BADGE_CODE, markerPayload(1)); + insertSnapshot(GITHUB_ID, OTHER_BADGE_CODE, markerPayload(2)); + + // when + String betaTester = repository.findPayload(GITHUB_ID, BADGE_CODE); + String gpu = repository.findPayload(GITHUB_ID, OTHER_BADGE_CODE); + + // then: bs.badge_code = :badgeCode 조건이 행을 구분한다 + assertThat(MAPPER.readTree(betaTester).path("signupRank").asInt()).isEqualTo(1); + assertThat(MAPPER.readTree(gpu).path("signupRank").asInt()).isEqualTo(2); + } + + @Test + @DisplayName("같은 배지의 타인 스냅샷과 섞이지 않는다") + void 페이로드조회_같은배지_다른사용자_본인행만반환() throws Exception { + // given: 두 사용자가 같은 배지의 스냅샷을 각자 보유한다 + insertUser(GITHUB_ID, USERNAME); + insertUser(OTHER_GITHUB_ID, OTHER_USERNAME); + insertSnapshot(GITHUB_ID, BADGE_CODE, markerPayload(1)); + insertSnapshot(OTHER_GITHUB_ID, BADGE_CODE, markerPayload(2)); + + // when + String mine = repository.findPayload(GITHUB_ID, BADGE_CODE); + String others = repository.findPayload(OTHER_GITHUB_ID, BADGE_CODE); + + // then: bs.github_id = :githubId 조건이 타인의 행을 걸러낸다 + assertThat(MAPPER.readTree(mine).path("signupRank").asInt()).isEqualTo(1); + assertThat(MAPPER.readTree(others).path("signupRank").asInt()).isEqualTo(2); + } + } + + @Nested + @DisplayName("badge_compact_number() - 표시 문자열 계약") + class CompactNumber { + + @ParameterizedTest(name = "[{index}] {0} → \"{1}\"") + @CsvSource({ + "0, 0", + "128, 128", + "999, 999", + "1000, 1K", + "4200, 4.2K", + "1000000, 1M", + "2500000000, 2.5B", + "324000000000000, 324T" + }) + @DisplayName("단위별 대표값과 임계 경계(999/1000)가 기대 표기로 축약된다") + void 축약표시_단위별대표값과경계_기대문자열반환(long input, String expected) { + // given: V6가 만든 함수는 V7 적재와 다음 고정값 배지가 공유하는 코드 밖 약속이다 + + // when: Long 바인딩 = bigint 시그니처 계약 (sum()::numeric 타입 불일치 전례) + String actual = jdbc.queryForObject("SELECT badge_compact_number(?)", String.class, input); + + // then: 소수 첫째 자리 유지, .0은 제거 (1000 → "1.0K"가 아니라 "1K") + assertThat(actual).isEqualTo(expected); + } + } +} From e6a0518518be0a055aa35a9f5fafed2a6b00faf7 Mon Sep 17 00:00:00 2001 From: kobenlys Date: Wed, 26 Aug 2026 00:56:20 +0900 Subject: [PATCH 2/2] chore: bump version to 26.08.26 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index db25ff8..c1b5ca8 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ com.tokenphage tokenphage-api - 26.08.12 + 26.08.26 tokenphage-api