Skip to content

Commit fc85948

Browse files
authored
(#87) unit test baseline과 서비스 계층 테스트 도입
* test: 백엔드 unit test baseline과 서비스 계층 테스트 도입 - unit test runtime과 workflow verification baseline 정렬 - pure component와 domain service 계층 테스트 추가 - locale 및 wall-clock 의존을 줄이는 최소 seam 보강 * test: 리뷰 피드백과 fixture 안정성을 보강 - unresolved PR 리뷰 스레드의 테스트 안정성 피드백 반영 - TestFixtures 의존을 단순화해 IDE 해석 안정성 개선 - badge diff 포맷과 응답 단언 범위를 보강
1 parent 3552ddf commit fc85948

26 files changed

Lines changed: 1506 additions & 18 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ on:
1212

1313
jobs:
1414
verify:
15-
name: Build Verification
15+
name: Test and Build Verification
1616
runs-on: ubuntu-latest
1717

1818
steps:
@@ -29,5 +29,8 @@ jobs:
2929
- name: Grant execute permission for gradlew
3030
run: chmod +x gradlew
3131

32-
- name: Run packaging build
33-
run: ./gradlew build
32+
- name: Run unit tests
33+
run: ./gradlew test
34+
35+
- name: Run packaging build without rerunning tests
36+
run: ./gradlew build -x test

.github/workflows/deploy.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ env:
1313

1414
jobs:
1515
verify:
16-
name: Pre-deploy Build Verification
16+
name: Pre-deploy Test and Build Verification
1717
runs-on: ubuntu-latest
1818

1919
steps:
@@ -30,8 +30,11 @@ jobs:
3030
- name: Grant execute permission for gradlew
3131
run: chmod +x gradlew
3232

33-
- name: Run packaging build
34-
run: ./gradlew build
33+
- name: Run unit tests
34+
run: ./gradlew test
35+
36+
- name: Run packaging build without rerunning tests
37+
run: ./gradlew build -x test
3538

3639
docker:
3740
name: Build & Push Docker Image

AGENTS.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
2. `build.gradle`, `settings.gradle`에서 모듈 구조, 의존성, verification task를 확인한다.
99
3. `.github/workflows/ci.yml`, `.github/workflows/deploy.yml`에서 CI verification lane과 pre-deploy gate를 확인한다.
1010
4. 테스트 코드 작성이나 TDD 기반 기능 구현 요청이면 `.codex/skills/README.md``red`, `green`, `refactor` skill을 먼저 확인한다.
11-
5. 현재 `src/test/` tree는 baseline reset 상태이므로, follow-up verification rebuild 전까지는 `build.gradle`과 workflow가 retained build-only lane을 어떻게 고정하는지 먼저 본다.
11+
5. `src/test/` tree와 `build.gradle`의 test runtime 구성을 함께 확인하고, unit test baseline이 `./gradlew test` 기준으로 정렬되어 있는지 본다.
1212
6. `src/main/java/`, `src/main/resources/`에서 실제 구현과 runtime config를 읽는다.
1313

1414
## Source Of Truth
@@ -24,11 +24,12 @@
2424

2525
- backend 구현 세부사항은 workflow repo 문서가 아니라 이 저장소의 문서, 설정, 코드, 테스트가 canonical source다.
2626
- root `README.md`는 개요만 유지한다. concrete bootstrap, verification, entrypoint 설명은 `AGENTS.md`와 named entry docs가 맡는다.
27-
- 현재 verification baseline command set은 `./gradlew build`다.
28-
- current baseline에는 dedicated test/integration lane이 없다. follow-up rebuild가 필요하면 `build.gradle`, workflow YAML, `AGENTS.md`를 함께 갱신한 뒤 repo-local baseline으로 다시 도입한다.
27+
- 현재 verification baseline command set은 `./gradlew test`, `./gradlew build`다.
28+
- current baseline은 unit-test gate를 포함하지만 dedicated integration lane은 없다. follow-up rebuild가 필요하면 `build.gradle`, workflow YAML, `AGENTS.md`를 함께 갱신한 뒤 repo-local baseline으로 다시 도입한다.
29+
- CI와 deploy verification에서 `./gradlew test`를 먼저 실행한 뒤 packaging build는 `./gradlew build -x test`로 수행해 test suite를 중복 실행하지 않는다.
2930
- verification lane이나 deploy gate를 바꾸면 `build.gradle`, workflow YAML, `AGENTS.md`를 함께 맞춘다.
3031
- repo-local TDD workflow는 `.codex/skills/red`, `.codex/skills/green`, `.codex/skills/refactor`가 소유한다.
3132
- 테스트 코드 작성 요청이나 TDD 기반 기능 구현 요청이 들어오면 기본 순서를 `red -> green -> refactor`로 고정한다.
3233
- `red`는 failing test와 현재 slice를 잠그고, `green`은 최소 production 변경으로 pass를 만들고, `refactor`는 green을 유지한 채 구조만 정리한다.
33-
- 현재 baseline이 build-only 상태이므로, test dependency 추가나 verification lane rebuild가 필요하면 그 범위를 spec에 먼저 잠그고 `build.gradle`, workflow YAML, `AGENTS.md`를 함께 갱신한다.
34+
- unit test baseline을 바꾸거나 test dependency를 조정할 때는 그 범위를 spec에 먼저 잠그고 `build.gradle`, workflow YAML, `AGENTS.md`를 함께 갱신한다.
3435
- repo-local skill로 해결되지 않는 작업만 이 문서와 nearest code/test를 추가로 읽고 진행한다.

build.gradle

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,14 @@ dependencies {
4646
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
4747
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
4848
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
49+
testImplementation 'org.springframework.boot:spring-boot-starter-test'
50+
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
4951
}
52+
53+
tasks.named('test') {
54+
useJUnitPlatform()
55+
}
56+
5057
tasks.named('processResources') {
5158
filteringCharset = 'UTF-8'
5259
filesMatching('static/swagger-ui/index.html') {

src/main/java/com/gitranker/api/domain/badge/BadgeFormatter.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,34 @@
22

33
import org.springframework.stereotype.Component;
44

5+
import java.util.Locale;
6+
57
/**
68
* 배지에 표시되는 숫자 및 텍스트 포맷팅을 담당하는 컴포넌트.
79
*/
810
@Component
911
public class BadgeFormatter {
1012

1113
public String formatNumber(long number) {
12-
return String.format("%,d", number);
14+
return String.format(Locale.US, "%,d", number);
1315
}
1416

1517
public String formatCount(int count) {
16-
return String.format("%,d", count);
18+
return String.format(Locale.US, "%,d", count);
1719
}
1820

1921
public String formatDiff(int diff) {
2022
if (diff > 0) {
21-
return String.format("<tspan class='diff-plus' dy='-1'>+%d</tspan>", diff);
23+
return String.format(Locale.US, "<tspan class='diff-plus' dy='-1'>+%d</tspan>", diff);
2224
}
2325
if (diff < 0) {
24-
return String.format("<tspan class='diff-minus' dy='-1'>-%d</tspan>", Math.abs(diff));
26+
return String.format(Locale.US, "<tspan class='diff-minus' dy='-1'>-%d</tspan>", Math.abs(diff));
2527
}
2628
return "";
2729
}
2830

2931
public String formatTierName(String tierName) {
30-
return tierName.charAt(0) + tierName.substring(1).toLowerCase();
32+
return tierName.charAt(0) + tierName.substring(1).toLowerCase(Locale.ROOT);
3133
}
3234

3335
public int calculateTierFontSize(String displayTierName) {

src/main/java/com/gitranker/api/domain/badge/BadgeService.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public String generateBadge(String nodeId) {
3535

3636
ActivityLog activityLog = Optional.ofNullable(
3737
activityLogRepository.getTopByUserOrderByActivityDateDesc(user)
38-
).orElseGet(() -> ActivityLog.empty(user, LocalDate.now()));
38+
).orElseGet(() -> ActivityLog.empty(user, currentDate()));
3939

4040
LogContext.event(Event.BADGE_VIEWED)
4141
.with("target_username", user.getUsername())
@@ -71,4 +71,8 @@ public String generateBadgeByTier(Tier tier) {
7171

7272
return svgBadgeRenderer.render(user, tier, activityLog);
7373
}
74+
75+
LocalDate currentDate() {
76+
return LocalDate.now();
77+
}
7478
}

src/main/java/com/gitranker/api/domain/badge/SvgBadgeRenderer.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import lombok.RequiredArgsConstructor;
77
import org.springframework.stereotype.Component;
88

9+
import java.util.Locale;
10+
911
/**
1012
* SVG 배지 렌더링을 담당하는 컴포넌트.
1113
*/
@@ -24,7 +26,7 @@ public String render(User user, Tier tier, ActivityLog activityLog) {
2426
String displayTierName = formatter.formatTierName(tier.name());
2527
int tierFontSize = formatter.calculateTierFontSize(displayTierName);
2628

27-
return String.format(SVG_TEMPLATE,
29+
return String.format(Locale.US, SVG_TEMPLATE,
2830
gradientDefs,
2931
FONT_IMPORT_CSS,
3032
tierFontSize,

src/main/java/com/gitranker/api/domain/user/service/BaselineStatsCalculator.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public class BaselineStatsCalculator {
2020
private final GitHubDataMapper gitHubDataMapper;
2121

2222
public ActivityStatistics calculate(User user, GitHubAllActivitiesResponse rawResponse) {
23-
int currentYear = LocalDate.now().getYear();
23+
int currentYear = currentDate().getYear();
2424
int userJoinYear = user.getGithubCreatedAt().getYear();
2525

2626
if (userJoinYear < currentYear) {
@@ -30,4 +30,8 @@ public ActivityStatistics calculate(User user, GitHubAllActivitiesResponse rawRe
3030

3131
return null;
3232
}
33+
34+
LocalDate currentDate() {
35+
return LocalDate.now();
36+
}
3337
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package com.gitranker.api.domain.auth.service;
2+
3+
import com.gitranker.api.domain.auth.RefreshToken;
4+
import com.gitranker.api.domain.auth.RefreshTokenRepository;
5+
import com.gitranker.api.domain.user.Role;
6+
import com.gitranker.api.domain.user.User;
7+
import com.gitranker.api.global.auth.AuthCookieManager;
8+
import com.gitranker.api.global.auth.jwt.JwtProvider;
9+
import com.gitranker.api.global.error.ErrorType;
10+
import com.gitranker.api.global.error.exception.BusinessException;
11+
import jakarta.servlet.http.HttpServletRequest;
12+
import jakarta.servlet.http.HttpServletResponse;
13+
import jakarta.servlet.http.HttpSession;
14+
import org.junit.jupiter.api.DisplayName;
15+
import org.junit.jupiter.api.Test;
16+
import org.junit.jupiter.api.extension.ExtendWith;
17+
import org.mockito.InjectMocks;
18+
import org.mockito.Mock;
19+
import org.mockito.junit.jupiter.MockitoExtension;
20+
21+
import java.time.LocalDateTime;
22+
import java.util.Optional;
23+
24+
import static com.gitranker.api.support.TestFixtures.refreshToken;
25+
import static com.gitranker.api.support.TestFixtures.savedUser;
26+
import static org.assertj.core.api.Assertions.assertThat;
27+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
28+
import static org.mockito.Mockito.mock;
29+
import static org.mockito.Mockito.never;
30+
import static org.mockito.Mockito.verify;
31+
import static org.mockito.Mockito.when;
32+
33+
@ExtendWith(MockitoExtension.class)
34+
class AuthServiceTest {
35+
36+
@InjectMocks
37+
private AuthService authService;
38+
39+
@Mock
40+
private RefreshTokenRepository refreshTokenRepository;
41+
@Mock
42+
private RefreshTokenService refreshTokenService;
43+
@Mock
44+
private JwtProvider jwtProvider;
45+
@Mock
46+
private AuthCookieManager authCookieManager;
47+
48+
@Test
49+
@DisplayName("유효한 refresh token이면 새 access/refresh token 쿠키를 발급한다")
50+
void refreshesAccessTokenForValidRefreshToken() {
51+
User user = savedUser(1L, "alice");
52+
RefreshToken refreshToken = refreshToken(user, "valid-token", validExpiry());
53+
HttpServletResponse response = mock(HttpServletResponse.class);
54+
55+
when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken));
56+
when(jwtProvider.createAccessToken("alice", Role.USER)).thenReturn("new-access-token");
57+
when(refreshTokenService.issueRefreshToken(user)).thenReturn("new-refresh-token");
58+
59+
authService.refreshAccessToken("valid-token", response);
60+
61+
verify(authCookieManager).addAccessTokenCookie(response, "new-access-token");
62+
verify(authCookieManager).addRefreshTokenCookie(response, "new-refresh-token");
63+
}
64+
65+
@Test
66+
@DisplayName("존재하지 않는 refresh token이면 INVALID_REFRESH_TOKEN 예외가 발생한다")
67+
void throwsWhenRefreshTokenDoesNotExist() {
68+
when(refreshTokenRepository.findByToken("missing")).thenReturn(Optional.empty());
69+
70+
assertThatThrownBy(() -> authService.refreshAccessToken("missing", mock(HttpServletResponse.class)))
71+
.isInstanceOf(BusinessException.class)
72+
.extracting(exception -> ((BusinessException) exception).getErrorType())
73+
.isEqualTo(ErrorType.INVALID_REFRESH_TOKEN);
74+
}
75+
76+
@Test
77+
@DisplayName("만료된 refresh token이면 삭제 후 EXPIRED_REFRESH_TOKEN 예외가 발생한다")
78+
void deletesExpiredRefreshTokenBeforeThrowing() {
79+
User user = savedUser(1L, "alice");
80+
RefreshToken expiredToken = refreshToken(user, "expired-token", expiredAt());
81+
82+
when(refreshTokenRepository.findByToken("expired-token")).thenReturn(Optional.of(expiredToken));
83+
84+
assertThatThrownBy(() -> authService.refreshAccessToken("expired-token", mock(HttpServletResponse.class)))
85+
.isInstanceOf(BusinessException.class)
86+
.extracting(exception -> ((BusinessException) exception).getErrorType())
87+
.isEqualTo(ErrorType.EXPIRED_REFRESH_TOKEN);
88+
89+
verify(refreshTokenRepository).delete(expiredToken);
90+
verify(authCookieManager, never()).addAccessTokenCookie(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString());
91+
}
92+
93+
@Test
94+
@DisplayName("본인 token으로 logout하면 token을 삭제하고 쿠키와 세션을 정리한다")
95+
void logsOutAndClearsSessionForMatchingUser() {
96+
User user = savedUser(1L, "alice");
97+
RefreshToken refreshToken = refreshToken(user, "valid-token", validExpiry());
98+
HttpServletRequest request = mock(HttpServletRequest.class);
99+
HttpServletResponse response = mock(HttpServletResponse.class);
100+
HttpSession session = mock(HttpSession.class);
101+
102+
when(refreshTokenRepository.findByToken("valid-token")).thenReturn(Optional.of(refreshToken));
103+
when(request.getSession(false)).thenReturn(session);
104+
105+
authService.logout(user, "valid-token", request, response);
106+
107+
verify(refreshTokenRepository).deleteByToken("valid-token");
108+
verify(authCookieManager).clearAccessTokenCookie(response);
109+
verify(authCookieManager).clearRefreshTokenCookie(response);
110+
verify(session).invalidate();
111+
}
112+
113+
@Test
114+
@DisplayName("다른 사용자의 token으로 logout하면 FORBIDDEN 예외가 발생한다")
115+
void throwsForbiddenForOtherUsersToken() {
116+
User currentUser = savedUser(1L, "alice");
117+
User otherUser = savedUser(2L, "bob");
118+
RefreshToken refreshToken = refreshToken(otherUser, "foreign-token", validExpiry());
119+
120+
when(refreshTokenRepository.findByToken("foreign-token")).thenReturn(Optional.of(refreshToken));
121+
122+
assertThatThrownBy(() -> authService.logout(currentUser, "foreign-token", mock(HttpServletRequest.class), mock(HttpServletResponse.class)))
123+
.isInstanceOf(BusinessException.class)
124+
.extracting(exception -> ((BusinessException) exception).getErrorType())
125+
.isEqualTo(ErrorType.FORBIDDEN);
126+
}
127+
128+
@Test
129+
@DisplayName("logoutAll은 사용자의 모든 token과 쿠키를 정리한다")
130+
void logoutAllClearsAllTokensAndCookies() {
131+
User user = savedUser(1L, "alice");
132+
HttpServletRequest request = mock(HttpServletRequest.class);
133+
HttpServletResponse response = mock(HttpServletResponse.class);
134+
HttpSession session = mock(HttpSession.class);
135+
136+
when(request.getSession(false)).thenReturn(session);
137+
138+
authService.logoutAll(user, request, response);
139+
140+
verify(refreshTokenRepository).deleteAllByUser(user);
141+
verify(authCookieManager).clearAccessTokenCookie(response);
142+
verify(authCookieManager).clearRefreshTokenCookie(response);
143+
verify(session).invalidate();
144+
}
145+
146+
private LocalDateTime validExpiry() {
147+
return LocalDateTime.now().plusDays(1);
148+
}
149+
150+
private LocalDateTime expiredAt() {
151+
return LocalDateTime.now().minusDays(1);
152+
}
153+
}

0 commit comments

Comments
 (0)