Skip to content

Commit 1fe818d

Browse files
committed
test: 테스트 커버리지 74%로 상승
- authentication, common 패키지 단위 테스트 추가 - Auth2UserInfo, UserPrincipal 도메인 테스트 작성 - GlobalExceptionHandler, RequestUUIDFilter 테스트 작성 - 전체 테스트 커버리지 74%
1 parent 3885a8d commit 1fe818d

File tree

20 files changed

+2945
-7
lines changed

20 files changed

+2945
-7
lines changed

build.gradle

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ plugins {
22
id 'java'
33
id 'org.springframework.boot' version '3.4.1'
44
id 'io.spring.dependency-management' version '1.1.7'
5+
id 'jacoco'
56
}
67

78
group = 'org.fontory'
@@ -86,4 +87,27 @@ dependencies {
8687

8788
tasks.named('test') {
8889
useJUnitPlatform()
90+
finalizedBy jacocoTestReport
91+
}
92+
93+
jacocoTestReport {
94+
dependsOn test
95+
reports {
96+
xml.required = true
97+
html.required = true
98+
}
99+
}
100+
101+
jacoco {
102+
toolVersion = '0.8.11'
103+
}
104+
105+
jacocoTestCoverageVerification {
106+
violationRules {
107+
rule {
108+
limit {
109+
minimum = 0.85
110+
}
111+
}
112+
}
89113
}

src/main/java/org/fontory/fontorybe/bookmark/service/BookmarkServiceImpl.java

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,24 +70,60 @@ public BookmarkDeleteResponse delete(Long memberId, Long fontId) {
7070
public Page<FontResponse> getBookmarkedFonts(Long memberId, int page, int size, String keyword) {
7171
Member member = memberLookupService.getOrThrowById(memberId);
7272

73-
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt")));
74-
Page<Bookmark> bookmarks = bookmarkRepository.findAllByMemberId(memberId, pageRequest);
73+
// If no keyword, use normal pagination
74+
if (!StringUtils.hasText(keyword)) {
75+
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt")));
76+
Page<Bookmark> bookmarks = bookmarkRepository.findAllByMemberId(memberId, pageRequest);
77+
78+
List<Long> fontIds = bookmarks.stream()
79+
.map(Bookmark::getFontId)
80+
.toList();
81+
82+
List<Font> fonts = fontRepository.findAllByIdIn(fontIds);
83+
84+
List<FontResponse> fontResponses = fonts.stream()
85+
.map(font -> {
86+
Member writer = memberLookupService.getOrThrowById(font.getMemberId());
87+
String woff2Url = cloudStorageService.getWoff2Url(font.getKey());
88+
return FontResponse.from(font, true, writer.getNickname(), woff2Url);
89+
})
90+
.toList();
91+
92+
return new PageImpl<>(fontResponses, pageRequest, bookmarks.getTotalElements());
93+
}
7594

76-
List<Long> fontIds = bookmarks.stream()
95+
// With keyword, need to filter all bookmarks first, then paginate
96+
// Get all bookmarks for the member (no pagination)
97+
PageRequest allBookmarksRequest = PageRequest.of(0, Integer.MAX_VALUE, Sort.by(Sort.Order.desc("createdAt")));
98+
Page<Bookmark> allBookmarks = bookmarkRepository.findAllByMemberId(memberId, allBookmarksRequest);
99+
100+
List<Long> allFontIds = allBookmarks.stream()
77101
.map(Bookmark::getFontId)
78102
.toList();
79103

80-
List<Font> fonts = fontRepository.findAllByIdIn(fontIds);
104+
List<Font> allFonts = fontRepository.findAllByIdIn(allFontIds);
81105

82-
List<FontResponse> filtered = fonts.stream()
83-
.filter(font -> !StringUtils.hasText(keyword) || font.getName().contains(keyword))
106+
// Filter by keyword
107+
List<Font> filteredFonts = allFonts.stream()
108+
.filter(font -> font.getName().contains(keyword))
109+
.toList();
110+
111+
// Apply manual pagination
112+
int start = page * size;
113+
int end = Math.min(start + size, filteredFonts.size());
114+
115+
List<FontResponse> pageContent = filteredFonts.subList(
116+
Math.min(start, filteredFonts.size()),
117+
end
118+
).stream()
84119
.map(font -> {
85120
Member writer = memberLookupService.getOrThrowById(font.getMemberId());
86121
String woff2Url = cloudStorageService.getWoff2Url(font.getKey());
87122
return FontResponse.from(font, true, writer.getNickname(), woff2Url);
88123
})
89124
.toList();
90125

91-
return new PageImpl<>(filtered, pageRequest, bookmarks.getTotalElements());
126+
PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Order.desc("createdAt")));
127+
return new PageImpl<>(pageContent, pageRequest, filteredFonts.size());
92128
}
93129
}
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
package org.fontory.fontorybe.integration.authentication.adapter.inbound;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import jakarta.servlet.http.Cookie;
5+
import org.fontory.fontorybe.authentication.application.AuthService;
6+
import org.fontory.fontorybe.authentication.application.port.JwtTokenProvider;
7+
import org.fontory.fontorybe.authentication.application.port.TokenStorage;
8+
import org.fontory.fontorybe.authentication.domain.UserPrincipal;
9+
import org.fontory.fontorybe.member.controller.port.MemberLookupService;
10+
import org.fontory.fontorybe.member.domain.Member;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.DisplayName;
13+
import org.junit.jupiter.api.Test;
14+
import org.springframework.beans.factory.annotation.Autowired;
15+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
16+
import org.springframework.boot.test.context.SpringBootTest;
17+
import org.springframework.test.context.bean.override.mockito.MockitoBean;
18+
import org.springframework.test.context.jdbc.Sql;
19+
import org.springframework.test.web.servlet.MockMvc;
20+
21+
import static org.fontory.fontorybe.TestConstants.*;
22+
import static org.mockito.ArgumentMatchers.any;
23+
import static org.mockito.BDDMockito.given;
24+
import static org.mockito.Mockito.verify;
25+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
26+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
27+
28+
/**
29+
* AuthController integration tests.
30+
* Tests authentication-related endpoints including logout functionality.
31+
*/
32+
@SpringBootTest
33+
@AutoConfigureMockMvc
34+
@Sql(value = "/sql/createMemberTestData.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
35+
@Sql(value = "/sql/deleteMemberTestData.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
36+
class AuthControllerIntegrationTest {
37+
38+
@Autowired
39+
private MockMvc mockMvc;
40+
41+
@Autowired
42+
private ObjectMapper objectMapper;
43+
44+
@Autowired
45+
private JwtTokenProvider jwtTokenProvider;
46+
47+
@Autowired
48+
private MemberLookupService memberLookupService;
49+
50+
@MockitoBean
51+
private TokenStorage tokenStorage;
52+
53+
private String validAccessToken;
54+
private String validRefreshToken;
55+
private Member testMember;
56+
private UserPrincipal userPrincipal;
57+
58+
@BeforeEach
59+
void setUp() {
60+
testMember = memberLookupService.getOrThrowById(TEST_MEMBER_ID);
61+
userPrincipal = UserPrincipal.from(testMember);
62+
63+
validAccessToken = jwtTokenProvider.generateAccessToken(userPrincipal);
64+
validRefreshToken = jwtTokenProvider.generateRefreshToken(userPrincipal);
65+
66+
// Mock token storage behavior
67+
given(tokenStorage.getRefreshToken(any(Member.class)))
68+
.willReturn(validRefreshToken);
69+
}
70+
71+
@Test
72+
@DisplayName("POST /auth/logout - successful logout with valid access token")
73+
void testLogoutSuccess() throws Exception {
74+
// Given: Valid access token in cookie
75+
Cookie accessTokenCookie = new Cookie("accessToken", validAccessToken);
76+
77+
// When: Performing logout request
78+
mockMvc.perform(post("/auth/logout")
79+
.cookie(accessTokenCookie))
80+
// Then: Should return 204 No Content
81+
.andExpect(status().isNoContent());
82+
83+
// Verify that token storage remove method was called
84+
verify(tokenStorage).removeRefreshToken(any(Member.class));
85+
}
86+
87+
@Test
88+
@DisplayName("POST /auth/logout - logout without authentication returns 401")
89+
void testLogoutWithoutAuthentication() throws Exception {
90+
// When: Performing logout request without access token
91+
mockMvc.perform(post("/auth/logout"))
92+
// Then: Should return 401 Unauthorized
93+
.andExpect(status().isUnauthorized())
94+
.andExpect(content().contentType("application/json;charset=UTF-8"))
95+
.andExpect(jsonPath("$.errorMessage").value("Authentication Required."));
96+
}
97+
98+
@Test
99+
@DisplayName("POST /auth/logout - logout with invalid access token returns 401")
100+
void testLogoutWithInvalidAccessToken() throws Exception {
101+
// Given: Invalid access token
102+
String invalidToken = "invalid.jwt.token";
103+
Cookie accessTokenCookie = new Cookie("accessToken", invalidToken);
104+
105+
// When: Performing logout request with invalid token
106+
mockMvc.perform(post("/auth/logout")
107+
.cookie(accessTokenCookie))
108+
// Then: Should return 401 Unauthorized
109+
.andExpect(status().isUnauthorized())
110+
.andExpect(content().contentType("application/json;charset=UTF-8"))
111+
.andExpect(jsonPath("$.errorMessage").value("Invalid access token"));
112+
}
113+
114+
@Test
115+
@DisplayName("POST /auth/logout - logout with expired access token returns 401")
116+
void testLogoutWithExpiredAccessToken() throws Exception {
117+
// Given: Expired access token (this is complex to simulate)
118+
// For this test, we'll use a malformed token that will fail validation
119+
String expiredToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.expired";
120+
Cookie accessTokenCookie = new Cookie("accessToken", expiredToken);
121+
122+
// When: Performing logout request with expired token
123+
mockMvc.perform(post("/auth/logout")
124+
.cookie(accessTokenCookie))
125+
// Then: Should return 401 Unauthorized
126+
.andExpect(status().isUnauthorized())
127+
.andExpect(content().contentType("application/json;charset=UTF-8"))
128+
.andExpect(jsonPath("$.errorMessage").value("Invalid access token"));
129+
}
130+
131+
@Test
132+
@DisplayName("POST /auth/logout - logout clears authentication cookies")
133+
void testLogoutClearsAuthCookies() throws Exception {
134+
// Given: Valid access token and refresh token
135+
Cookie accessTokenCookie = new Cookie("accessToken", validAccessToken);
136+
Cookie refreshTokenCookie = new Cookie("refreshToken", validRefreshToken);
137+
138+
// When: Performing logout request
139+
mockMvc.perform(post("/auth/logout")
140+
.cookie(accessTokenCookie)
141+
.cookie(refreshTokenCookie))
142+
// Then: Should return 204 and clear cookies
143+
.andExpect(status().isNoContent())
144+
// Verify that Set-Cookie headers are present to clear cookies
145+
.andExpect(header().exists("Set-Cookie"));
146+
147+
// Verify that refresh token was removed from storage
148+
verify(tokenStorage).removeRefreshToken(any(Member.class));
149+
}
150+
151+
@Test
152+
@DisplayName("POST /auth/logout - logout removes refresh token from storage")
153+
void testLogoutRemovesRefreshTokenFromStorage() throws Exception {
154+
// Given: Valid access token
155+
Cookie accessTokenCookie = new Cookie("accessToken", validAccessToken);
156+
157+
// When: Performing logout request
158+
mockMvc.perform(post("/auth/logout")
159+
.cookie(accessTokenCookie))
160+
.andExpect(status().isNoContent());
161+
162+
// Then: Verify that refresh token was removed from Redis storage
163+
verify(tokenStorage).removeRefreshToken(any(Member.class));
164+
}
165+
166+
@Test
167+
@DisplayName("POST /auth/logout - logout with non-existent member returns appropriate error")
168+
void testLogoutWithNonExistentMember() throws Exception {
169+
// Given: Valid JWT token for non-existent member
170+
UserPrincipal nonExistentUserPrincipal = new UserPrincipal(NON_EXIST_ID);
171+
String tokenForNonExistentUser = jwtTokenProvider.generateAccessToken(nonExistentUserPrincipal);
172+
Cookie accessTokenCookie = new Cookie("accessToken", tokenForNonExistentUser);
173+
174+
// When: Performing logout request
175+
mockMvc.perform(post("/auth/logout")
176+
.cookie(accessTokenCookie))
177+
// Then: Should return error due to member not found
178+
.andExpect(status().is4xxClientError());
179+
}
180+
181+
@Test
182+
@DisplayName("POST /auth/logout - multiple logout requests are idempotent")
183+
void testMultipleLogoutRequestsAreIdempotent() throws Exception {
184+
// Given: Valid access token
185+
Cookie accessTokenCookie = new Cookie("accessToken", validAccessToken);
186+
187+
// When: Performing first logout request
188+
mockMvc.perform(post("/auth/logout")
189+
.cookie(accessTokenCookie))
190+
.andExpect(status().isNoContent());
191+
192+
// When: Performing second logout request
193+
// Note: In an integration test, the same token can still be validated
194+
// since we're not actually clearing it from the JWT validation
195+
// but we are clearing it from Redis storage
196+
mockMvc.perform(post("/auth/logout")
197+
.cookie(accessTokenCookie))
198+
// The request should still succeed as JWT validation passes
199+
.andExpect(status().isNoContent());
200+
}
201+
202+
@Test
203+
@DisplayName("POST /auth/logout - only POST method is allowed")
204+
void testLogoutOnlyAllowsPostMethod() throws Exception {
205+
// Given: Valid access token
206+
Cookie accessTokenCookie = new Cookie("accessToken", validAccessToken);
207+
208+
// When: Attempting GET request to logout endpoint
209+
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get("/auth/logout")
210+
.cookie(accessTokenCookie))
211+
// Then: Should return 405 Method Not Allowed
212+
.andExpect(status().isMethodNotAllowed());
213+
214+
// When: Attempting DELETE request to logout endpoint
215+
mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete("/auth/logout")
216+
.cookie(accessTokenCookie))
217+
// Then: Should return 405 Method Not Allowed
218+
.andExpect(status().isMethodNotAllowed());
219+
}
220+
221+
@Test
222+
@DisplayName("POST /auth/logout - handles malformed JWT token gracefully")
223+
void testLogoutWithMalformedJwtToken() throws Exception {
224+
// Given: Malformed JWT token
225+
String malformedToken = "not.a.valid.jwt.token.at.all";
226+
Cookie accessTokenCookie = new Cookie("accessToken", malformedToken);
227+
228+
// When: Performing logout request with malformed token
229+
mockMvc.perform(post("/auth/logout")
230+
.cookie(accessTokenCookie))
231+
// Then: Should return 401 Unauthorized
232+
.andExpect(status().isUnauthorized())
233+
.andExpect(content().contentType("application/json;charset=UTF-8"))
234+
.andExpect(jsonPath("$.errorMessage").value("Invalid access token"));
235+
}
236+
}

0 commit comments

Comments
 (0)