Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ dependencies {
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'

testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'io.rest-assured:spring-mock-mvc:5.4.0'
Expand All @@ -71,6 +71,10 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

test {
useJUnitPlatform()
}

jacoco {
toolVersion = "0.8.12"
}
Expand Down
30 changes: 18 additions & 12 deletions src/main/java/Konkuk/U2E/domain/user/service/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -1,40 +1,46 @@
package Konkuk.U2E.domain.user.service;

import Konkuk.U2E.domain.user.exception.InvalidAccessTokenException;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.INVALID_ACCESS_TOKEN;

@Component
public class JwtUtil {

@Value("${jwt.secret}")
private String secretKey;
private final SecretKey secretKey;

@Value("${jwt.expiration}")
private long expirationMs;

public JwtUtil(@Value("${jwt.secret}") String secret) {
secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}

public String generateAccessToken(String username) {
return Jwts.builder()
.setSubject(username)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.claim("username", username)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(secretKey)
.compact();
}

public Claims validateAccessToken(String token) {
public String validateAndGetName(String token) {
try {
return Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload()
.get("username", String.class);
} catch (Exception e) {
throw new InvalidAccessTokenException(INVALID_ACCESS_TOKEN);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ public boolean preHandle(HttpServletRequest request, HttpServletResponse respons
}

if (token != null) {
Claims claims = jwtUtil.validateAccessToken(token);
request.setAttribute("username", claims.getSubject());
String userName = jwtUtil.validateAndGetName(token);
request.setAttribute("username", userName);
log.info("사용자 {} 인증", request.getAttribute("username"));
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package Konkuk.U2E.domain.comment.controller;

import Konkuk.U2E.domain.comment.dto.request.PostCommentCreateRequest;
import Konkuk.U2E.domain.comment.dto.response.GetCommentsResponse;
import Konkuk.U2E.domain.comment.service.CommentService;
import Konkuk.U2E.domain.user.service.JwtUtil;
import Konkuk.U2E.global.auth.resolver.LoginUserArgumentResolver;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import java.util.List;

import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@ActiveProfiles("test")
@SpringBootTest
@AutoConfigureMockMvc
class CommentControllerTest {

@Autowired MockMvc mockMvc;
@Autowired ObjectMapper om;

@MockitoBean CommentService commentService;

@MockitoBean LoginUserArgumentResolver loginUserArgumentResolver;

@BeforeEach
void setUpResolver() throws Exception {
when(loginUserArgumentResolver.supportsParameter(any())).thenReturn(true);
when(loginUserArgumentResolver.resolveArgument(any(), any(), any(), any()))
.thenReturn("test-user");
}

@MockitoBean
JwtUtil jwtUtil;

@BeforeEach
void setUp() {
when(jwtUtil.validateAndGetName("dummy-token")).thenReturn("test-user");
}

@Nested
@DisplayName("GET /comments/{newsId}")
class GetCommentsApi {

@Test
@DisplayName("댓글 목록 조회 – OK (BaseResponse.data.commentList)")
void getComments_ok() throws Exception {
// given
when(commentService.getCommentInfo(1L))
.thenReturn(new GetCommentsResponse(List.of()));

// when & then
mockMvc.perform(get("/comments/{newsId}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.data.commentList").isArray());
}
}

@Nested
@DisplayName("POST /comments")
class PostCommentsApi {

@Test
@DisplayName("댓글 작성 – OK (LoginUser가 'test-user'로 주입)")
void postComments_ok() throws Exception {
// given
PostCommentCreateRequest req = new PostCommentCreateRequest(5L, "hi");

// when & then
mockMvc.perform(post("/comments")
.header("Authorization", "Bearer dummy-token")
.contentType(MediaType.APPLICATION_JSON)
.content(om.writeValueAsString(req)))
.andExpect(status().isOk());

verify(commentService).createComment(eq("test-user"), any(PostCommentCreateRequest.class));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package Konkuk.U2E.domain.comment.service;

import Konkuk.U2E.domain.comment.domain.Comment;
import Konkuk.U2E.domain.comment.dto.request.PostCommentCreateRequest;
import Konkuk.U2E.domain.comment.dto.response.GetCommentsResponse;
import Konkuk.U2E.domain.comment.exception.CommentOfNewsNotFoundException;
import Konkuk.U2E.domain.comment.repository.CommentRepository;
import Konkuk.U2E.domain.news.domain.News;
import Konkuk.U2E.domain.news.repository.NewsRepository;
import Konkuk.U2E.domain.user.domain.User;
import Konkuk.U2E.domain.user.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@DisplayName("CommentService 단위 테스트")
class CommentServiceTest {

CommentRepository commentRepository = mock(CommentRepository.class);
NewsRepository newsRepository = mock(NewsRepository.class);
UserRepository userRepository = mock(UserRepository.class);

CommentService sut;

@BeforeEach
void setUp() {
sut = new CommentService(commentRepository, newsRepository, userRepository);
}

private News sampleNews() {
return News.builder()
.newsUrl("https://x")
.imageUrl(null)
.newsTitle("title")
.newsBody("body")
.newsDate(LocalDate.now())
.climateList(List.of())
.build();
}

@Test
@DisplayName("getCommentInfo - 뉴스가 없으면 예외")
void getCommentInfo_whenNewsMissing_thenThrow() {
when(newsRepository.findById(1L)).thenReturn(Optional.empty());

assertThatThrownBy(() -> sut.getCommentInfo(1L))
.isInstanceOf(CommentOfNewsNotFoundException.class);
}

@Test
@DisplayName("getCommentInfo - 정상 조회")
void getCommentInfo_whenExists_thenOk() {
News news = sampleNews();
when(newsRepository.findById(1L)).thenReturn(Optional.of(news));

Comment c = Comment.builder()
.contents("hello")
.news(news)
.user(User.builder().name("kim").password("p").build())
.build();

when(commentRepository.findCommentsByNewsId(1L)).thenReturn(List.of(c));

GetCommentsResponse res = sut.getCommentInfo(1L);

assertThat(res.commentList()).hasSize(1);
assertThat(res.commentList().get(0).contents()).isEqualTo("hello");
}

@Test
@DisplayName("createComment - 뉴스가 없으면 예외")
void createComment_whenNewsMissing_thenThrow() {
when(newsRepository.findById(9L)).thenReturn(Optional.empty());
when(userRepository.findUserByName("kim"))
.thenReturn(User.builder().name("kim").password("p").build());

PostCommentCreateRequest req = new PostCommentCreateRequest(9L, "hi");

assertThatThrownBy(() -> sut.createComment("kim", req))
.isInstanceOf(CommentOfNewsNotFoundException.class);
}

@Test
@DisplayName("createComment - 정상 저장")
void createComment_whenValid_thenSave() {
User user = User.builder().name("kim").password("p").build();
News news = sampleNews();
when(userRepository.findUserByName("kim")).thenReturn(user);
when(newsRepository.findById(7L)).thenReturn(Optional.of(news));

PostCommentCreateRequest req = new PostCommentCreateRequest(7L, "hi");
sut.createComment("kim", req);

verify(commentRepository, times(1)).save(any(Comment.class));
}
}
50 changes: 50 additions & 0 deletions src/test/java/Konkuk/U2E/domain/user/service/JwtUtilTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package Konkuk.U2E.domain.user.service;

import Konkuk.U2E.domain.user.exception.InvalidAccessTokenException;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.util.ReflectionTestUtils;

import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.INVALID_ACCESS_TOKEN;
import static org.assertj.core.api.Assertions.*;

@ActiveProfiles("test")
@DisplayName("JwtUtil 단위 테스트")
class JwtUtilTest {

private final JwtUtil jwtUtil = new JwtUtil("abcdefghakjfnjklafljkaklfjaslkdfjlkjkljklafjkkljflkaflksjklijklmnopqrstuvwxyz");

@Test
@DisplayName("토큰 생성/검증 - OK (username 클레임 반환)")
void generate_and_validate_ok() {
ReflectionTestUtils.setField(jwtUtil, "expirationMs", 3_600_000L); // 1시간

// when
String token = jwtUtil.generateAccessToken("tester");

// then
String name = jwtUtil.validateAndGetName(token);
assertThat(name).isEqualTo("tester");
}

@Test
@DisplayName("잘못된 토큰 - INVALID_ACCESS_TOKEN 예외")
void validate_invalidToken_thenThrow() {
assertThatThrownBy(() -> jwtUtil.validateAndGetName("not.jwt"))
.isInstanceOf(InvalidAccessTokenException.class)
.hasMessageContaining(INVALID_ACCESS_TOKEN.getMessage());
}

@Test
@DisplayName("만료된 토큰 - INVALID_ACCESS_TOKEN 예외")
void validate_expiredToken_thenThrow() {
// 만료 시간을 과거로 세팅해서 즉시 만료 토큰 생성
ReflectionTestUtils.setField(jwtUtil, "expirationMs", -1L);
String token = jwtUtil.generateAccessToken("tester");

assertThatThrownBy(() -> jwtUtil.validateAndGetName(token))
.isInstanceOf(InvalidAccessTokenException.class)
.hasMessageContaining(INVALID_ACCESS_TOKEN.getMessage());
}
}
6 changes: 5 additions & 1 deletion src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ spring:
name: U2E

jwt:
secret: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz
secret: abcdefghakjfnjklafljkaklfjaslkdfjlkjkljklafjkkljflkaflksjklijklmnopqrstuvwxyz
expiration: 3600000
---
spring:
Expand All @@ -28,3 +28,7 @@ spring:
dialect: org.hibernate.dialect.H2Dialect


gemini:
api-key: test-secret-key-1234567890-abcdefghijklmnopqrstuvwxyz-ABCDEFGH
model: gemini-2.5-flash
endpoint: https://generativelanguage.googleapis.com/v1beta