diff --git a/src/main/java/org/runimo/runimo/RunimoApplication.java b/src/main/java/org/runimo/runimo/RunimoApplication.java index 60e11866..1c40cad6 100644 --- a/src/main/java/org/runimo/runimo/RunimoApplication.java +++ b/src/main/java/org/runimo/runimo/RunimoApplication.java @@ -2,10 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication -@EnableJpaAuditing public class RunimoApplication { public static void main(String[] args) { diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java index 957a0502..2915615f 100644 --- a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java @@ -4,15 +4,15 @@ import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -@Component public class JwtResolver { private static final String ISSUER = "RUNIMO_SERVICE"; - @Value("${jwt.secret}") - private String jwtSecret; + private final String jwtSecret; + + public JwtResolver(String jwtSecret) { + this.jwtSecret = jwtSecret; + } public DecodedJWT verifyAccessToken(String token) throws JWTVerificationException { return JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(ISSUER).build().verify(token); diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java index 911bf008..3c650b00 100644 --- a/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java @@ -2,26 +2,24 @@ import com.auth0.jwt.JWT; import com.auth0.jwt.algorithms.Algorithm; -import lombok.RequiredArgsConstructor; import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.service.dtos.TokenPair; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; import java.util.Date; -@Component -@RequiredArgsConstructor public class JwtTokenFactory { private static final String ISSUER = "RUNIMO_SERVICE"; - @Value("${jwt.secret}") - private String jwtSecret; - @Value("${jwt.expiration}") - private long jwtExpiration; - @Value("${jwt.refresh.expiration}") - private long jwtRefreshExpiration; + private final String jwtSecret; + private final long jwtExpiration; + private final long jwtRefreshExpiration; + + public JwtTokenFactory(String jwtSecret, long jwtExpiration, long jwtRefreshExpiration) { + this.jwtSecret = jwtSecret; + this.jwtExpiration = jwtExpiration; + this.jwtRefreshExpiration = jwtRefreshExpiration; + } public String generateAccessToken(String userPublicId) { Date now = new Date(); diff --git a/src/main/java/org/runimo/runimo/config/JpaAuditingConfiguration.java b/src/main/java/org/runimo/runimo/config/JpaAuditingConfiguration.java new file mode 100644 index 00000000..f844aa2d --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/JpaAuditingConfiguration.java @@ -0,0 +1,9 @@ +package org.runimo.runimo.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfiguration { +} diff --git a/src/main/java/org/runimo/runimo/config/JwtConfig.java b/src/main/java/org/runimo/runimo/config/JwtConfig.java new file mode 100644 index 00000000..059beb56 --- /dev/null +++ b/src/main/java/org/runimo/runimo/config/JwtConfig.java @@ -0,0 +1,31 @@ +package org.runimo.runimo.config; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class JwtConfig { + + @Value("${jwt.secret}") + private String jwtSecret; + @Value("${jwt.expiration}") + private long jwtExpiration; + @Value("${jwt.refresh.expiration}") + private long jwtRefreshExpiration; + + @Bean + public JwtTokenFactory jwtTokenFactory() { + return new JwtTokenFactory(jwtSecret, jwtExpiration, jwtRefreshExpiration); + } + + @Bean + public JwtResolver jwtResolver() { + return new JwtResolver(jwtSecret); + } + +} diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java index 3ba6ca54..d00af4e3 100644 --- a/src/main/java/org/runimo/runimo/config/SecurityConfig.java +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -10,6 +10,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @@ -18,6 +19,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AuthenticationFailureHandler customAuthenticationFailureHandler; @Bean @Profile({"prod", "test"}) @@ -27,6 +29,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) + .oauth2Login(oAuth2Login -> { + oAuth2Login + .loginPage("/api/v1/users/auth/login") + .failureHandler(customAuthenticationFailureHandler); + }) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/v1/users/auth/**").permitAll() .anyRequest().authenticated() @@ -44,6 +51,11 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) + .oauth2Login(oAuth2Login -> { + oAuth2Login + .loginPage("/api/v1/users/auth/login") + .failureHandler(customAuthenticationFailureHandler); + }) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/v1/users/auth/**").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() diff --git a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java index dc06c094..6921b160 100644 --- a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java +++ b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java @@ -12,6 +12,7 @@ import org.runimo.runimo.common.scale.Pace; import org.runimo.runimo.records.service.usecases.dtos.SegmentPace; +import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Objects; @@ -89,6 +90,9 @@ public Distance getTotalDistance() { return new Distance(totalDistance.getAmount()); } + public Duration getRunningTime() { + return Duration.between(startedAt, endAt); + } private void validateEditor(Long editorId) { if (editorId == null || !Objects.equals(this.userId, editorId)) { throw new IllegalArgumentException("Invalid editor id"); diff --git a/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java b/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java index 25e4e9a3..c1922255 100644 --- a/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java +++ b/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java @@ -23,6 +23,9 @@ public String convertToDatabaseColumn(List segmentPaces) { public List convertToEntityAttribute(String s) { TypeReference> typeRef = new TypeReference>() {}; try { + if(s == null || s.isEmpty()) { + return List.of(); + } return objectMapper.readValue(s, typeRef); } catch (Exception e) { throw new RuntimeException("Failed to convert JSON to segmentPaces", e); diff --git a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java index 6625d8d0..e870bcf9 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -27,4 +27,7 @@ Slice findFirstRunOfWeek( @Query("SELECT COUNT(r.id) FROM RunningRecord r WHERE r.userId = :id") Long countByUserId(Long id); + + @Query("select r from RunningRecord r where r.userId = :userId") + Slice findLatestByUserId(Long userId, Pageable pageRequest); } diff --git a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java index b5b1e40f..6c3ae7e5 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -39,4 +39,10 @@ public Optional findFirstRunOfCurrentWeek(Long userId) { public Long countByUserId(Long userId) { return recordRepository.countByUserId(userId); } + + @Transactional(readOnly = true) + public Optional findLatestRunningRecordByUserId(Long userId) { + PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("startedAt").descending()); + return recordRepository.findLatestByUserId(userId, pageRequest).stream().findFirst(); + } } diff --git a/src/main/java/org/runimo/runimo/user/controller/MyPageController.java b/src/main/java/org/runimo/runimo/user/controller/MyPageController.java new file mode 100644 index 00000000..2a64d4a6 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/MyPageController.java @@ -0,0 +1,27 @@ +package org.runimo.runimo.user.controller; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.service.dtos.MyPageViewResponse; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.service.usecases.query.MyPageQueryUsecase; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/users/me") +@RequiredArgsConstructor +public class MyPageController { + + private final MyPageQueryUsecase myPageQueryUsecase; + + @GetMapping + public ResponseEntity> queryMyPageView( + @UserId Long userId) { + MyPageViewResponse response = myPageQueryUsecase.execute(userId); + return ResponseEntity.ok(SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); + } + +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/LatestRunningRecord.java b/src/main/java/org/runimo/runimo/user/service/dtos/LatestRunningRecord.java new file mode 100644 index 00000000..fb0ab704 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/LatestRunningRecord.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.user.service.dtos; + +import java.time.LocalDateTime; + +public record LatestRunningRecord( + String title, + LocalDateTime startDateTime, + Long distanceInMeters, + Long durationInSeconds, + Long averagePaceInMiliseconds +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/MyPageViewResponse.java b/src/main/java/org/runimo/runimo/user/service/dtos/MyPageViewResponse.java new file mode 100644 index 00000000..a868cda3 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/MyPageViewResponse.java @@ -0,0 +1,10 @@ +package org.runimo.runimo.user.service.dtos; + +public record MyPageViewResponse( + String nickname, + String profileImageUrl, + Long totalDistanceInMeters, + Long latestRunDateBefore, + LatestRunningRecord latestRunningRecord +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecase.java new file mode 100644 index 00000000..20d111cf --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecase.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.user.service.usecases.query; + +import org.runimo.runimo.user.service.dtos.MyPageViewResponse; + +public interface MyPageQueryUsecase { + MyPageViewResponse execute(Long userId); +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecaseImpl.java new file mode 100644 index 00000000..1a013008 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/query/MyPageQueryUsecaseImpl.java @@ -0,0 +1,46 @@ +package org.runimo.runimo.user.service.usecases.query; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.user.service.dtos.MyPageViewResponse; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.dtos.LatestRunningRecord; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class MyPageQueryUsecaseImpl implements MyPageQueryUsecase { + + private final UserFinder userFinder; + private final RecordFinder recordFinder; + + @Override + public MyPageViewResponse execute(Long userId) { + + User user = userFinder.findUserById(userId) + .orElseThrow(NoSuchElementException::new); + RunningRecord runningRecord = recordFinder.findLatestRunningRecordByUserId(userId) + .orElseThrow(NoSuchElementException::new); + Long differenceBetweenTodayAndLastRunningDate = + ChronoUnit.DAYS.between(runningRecord.getStartedAt(), LocalDateTime.now()); + return new MyPageViewResponse( + user.getNickname(), + user.getImgUrl(), + user.getTotalDistanceInMeters(), + differenceBetweenTodayAndLastRunningDate, + new LatestRunningRecord( + runningRecord.getTitle(), + runningRecord.getStartedAt(), + runningRecord.getTotalDistance().getAmount(), + runningRecord.getRunningTime().getSeconds(), + runningRecord.getAveragePace().getPaceInMilliSeconds() + ) + ); + } +} diff --git a/src/test/java/org/runimo/runimo/configs/ControllerTest.java b/src/test/java/org/runimo/runimo/configs/ControllerTest.java new file mode 100644 index 00000000..af988851 --- /dev/null +++ b/src/test/java/org/runimo/runimo/configs/ControllerTest.java @@ -0,0 +1,21 @@ +package org.runimo.runimo.configs; + +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ActiveProfiles("test") +@WebMvcTest +@AutoConfigureMockMvc +@Import({TestConfig.class, TestWebMvcConfig.class, TestSecurityConfig.class}) +public @interface ControllerTest { + Class[] controllers() default {}; +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/configs/TestConfig.java b/src/test/java/org/runimo/runimo/configs/TestConfig.java new file mode 100644 index 00000000..104d00fe --- /dev/null +++ b/src/test/java/org/runimo/runimo/configs/TestConfig.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.configs; + +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestConfig { + + @Bean + public JwtTokenFactory jwtTokenFactory() { + return new JwtTokenFactory("testSecret", 1000L, 3600L); + } + + @Bean + public JwtResolver jwtResolver() { + return new JwtResolver("testSecret"); + } +} diff --git a/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java b/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java new file mode 100644 index 00000000..423ee080 --- /dev/null +++ b/src/test/java/org/runimo/runimo/configs/TestSecurityConfig.java @@ -0,0 +1,51 @@ +package org.runimo.runimo.configs; + +import org.runimo.runimo.auth.filters.JwtAuthenticationFilter; +import org.runimo.runimo.config.CustomAuthenticationFailureHandler; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@TestConfiguration +@EnableWebSecurity +@Import(CustomAuthenticationFailureHandler.class) +public class TestSecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final AuthenticationFailureHandler customAuthenticationFailureHandler; + + public TestSecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, + AuthenticationFailureHandler customAuthenticationFailureHandler) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + this.customAuthenticationFailureHandler = customAuthenticationFailureHandler; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .oauth2Login(oAuth2Login -> { + oAuth2Login + .loginPage("/api/v1/users/auth/login") + .failureHandler(customAuthenticationFailureHandler); + }) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/v1/users/auth/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} + diff --git a/src/test/java/org/runimo/runimo/configs/TestWebMvcConfig.java b/src/test/java/org/runimo/runimo/configs/TestWebMvcConfig.java new file mode 100644 index 00000000..728f4429 --- /dev/null +++ b/src/test/java/org/runimo/runimo/configs/TestWebMvcConfig.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.configs; + +import org.runimo.runimo.user.controller.UserIdResolver; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@TestConfiguration +public class TestWebMvcConfig implements WebMvcConfigurer { + + private final UserIdResolver userIdResolver; + + public TestWebMvcConfig(UserIdResolver userIdResolver) { + this.userIdResolver = userIdResolver; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdResolver); + } +} diff --git a/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java new file mode 100644 index 00000000..a7d2a758 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/MyPageAcceptanceTest.java @@ -0,0 +1,68 @@ +package org.runimo.runimo.user.api; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +public class MyPageAcceptanceTest { + + @LocalServerPort + private Integer port; + @Autowired + private CleanUpUtil cleanUpUtil; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + + @Test + @Sql(scripts = "/sql/user_mypage_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 마이페이지_조회_시_프로필정보와_최근_달리기가_표시된다() { + + String accessToken = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + + given() + .header("Authorization", accessToken) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/users/me") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("code", equalTo("USH2001")) + .body("payload.nickname", equalTo("Daniel")) + .body("payload.profile_image_url", equalTo("https://example.com/images/user1.png")) + .body("payload.total_distance_in_meters", equalTo(10000)) + .body("payload.latest_run_date_before", equalTo(2)) + .body("payload.latest_running_record.title", equalTo("record-title-2")) + .body("payload.latest_running_record.start_date_time", equalTo("2025-03-29T13:00:00")) + .body("payload.latest_running_record.distance_in_meters", equalTo(2345)) + .body("payload.latest_running_record.duration_in_seconds", equalTo(3600)) + .body("payload.latest_running_record.average_pace_in_miliseconds", equalTo(6700)); + } + +} diff --git a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java index 31bdd206..0dc96e1a 100644 --- a/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/user/api/UserItemAcceptanceTest.java @@ -27,7 +27,6 @@ import static org.mockito.Mockito.when; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) - @ActiveProfiles("test") class UserItemAcceptanceTest { diff --git a/src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java similarity index 88% rename from src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java rename to src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java index e2347a03..2a89ebcf 100644 --- a/src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/MainViewControllerTest.java @@ -1,16 +1,14 @@ -package org.runimo.runimo.user.api; +package org.runimo.runimo.user.controller; import org.junit.jupiter.api.Test; import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.configs.ControllerTest; import org.runimo.runimo.user.service.usecases.query.MainViewQueryUsecase; import org.runimo.runimo.user.service.dtos.MainViewResponse; import org.runimo.runimo.user.UserFixtures; import org.runimo.runimo.user.service.UserFinder; 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 org.springframework.test.web.servlet.request.MockMvcRequestBuilders; @@ -21,9 +19,7 @@ import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") +@ControllerTest(controllers = {MainViewController.class}) class MainViewControllerTest { @Autowired diff --git a/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java new file mode 100644 index 00000000..22c10b1a --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/controller/MyPageControllerTest.java @@ -0,0 +1,118 @@ +package org.runimo.runimo.user.controller; + +import org.junit.jupiter.api.Test; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.configs.ControllerTest; +import org.runimo.runimo.user.service.dtos.MyPageViewResponse; +import org.runimo.runimo.user.service.dtos.LatestRunningRecord; +import org.runimo.runimo.user.service.usecases.query.MyPageQueryUsecase; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import javax.naming.NoPermissionException; +import java.time.LocalDateTime; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ControllerTest(controllers = {MyPageController.class}) +class MyPageControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private JwtTokenFactory jwtTokenFactory; + @MockitoBean + private MyPageQueryUsecase myPageQueryUsecase; + @MockitoBean + private JwtResolver jwtResolver; + @MockitoBean + private UserIdResolver userIdResolver; + + + @Test + @WithMockUser(username = "test-user-uuid-1") + void 마이_페이지_조회_성공() throws Exception { + // given + String accessToken = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + + MyPageViewResponse response = new MyPageViewResponse( + "Daniel", + "https://example.com/images/user1.png", + 2L, + 5L, + new LatestRunningRecord( + "활기차 모닝런", + LocalDateTime.of(2025, 3, 24,10,11), + 3000L, + 100L, + 6700L + ) + ); + + when(myPageQueryUsecase.execute(any())) + .thenReturn(response); + when(jwtResolver.getUserIdFromAccessToken(any())) + .thenReturn("test-user-uuid-1"); + when(userIdResolver.resolveArgument(any(), any(), any(), any())) + .thenReturn(1L); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/users/me") + .header("Authorization", accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.payload.nickname").value("Daniel")) + .andExpect(jsonPath("$.payload.profile_image_url").value("https://example.com/images/user1.png")) + .andExpect(jsonPath("$.payload.total_distance_in_meters").value(2L)) + .andExpect(jsonPath("$.payload.latest_run_date_before").value(5L)) + .andExpect(jsonPath("$.payload.latest_running_record.title").value("활기차 모닝런")) + .andExpect(jsonPath("$.payload.latest_running_record.start_date_time").exists()) + .andExpect(jsonPath("$.payload.latest_running_record.distance_in_meters").value(3000)) + .andExpect(jsonPath("$.payload.latest_running_record.duration_in_seconds").value(100)) + .andExpect(jsonPath("$.payload.latest_running_record.average_pace_in_miliseconds").value(6700)); + } + + @Test + @WithAnonymousUser + void 마이_페이지_조회_실패_인증되지_않은_사용자() throws Exception { + // when & then + when(userIdResolver.resolveArgument(any(), any(), any(), any())) + .thenThrow(NoPermissionException.class); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/users/me") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "test-user-uuid-3") + void 마이_페이지_조회_실패_사용자정보_없음() throws Exception { + // given + String accessToken = "Bearer " + jwtTokenFactory.generateAccessToken("non-existent-user"); + + when(myPageQueryUsecase.execute(any())) + .thenThrow(new RuntimeException("User not found")); + + // when & then + + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/users/me") + .header("Authorization", accessToken) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isUnauthorized()); + } +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/api/QueryItemControllerTest.java b/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java similarity index 85% rename from src/test/java/org/runimo/runimo/user/api/QueryItemControllerTest.java rename to src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java index e1b5f0e6..866e54ae 100644 --- a/src/test/java/org/runimo/runimo/user/api/QueryItemControllerTest.java +++ b/src/test/java/org/runimo/runimo/user/controller/QueryItemControllerTest.java @@ -1,15 +1,14 @@ -package org.runimo.runimo.user.api; +package org.runimo.runimo.user.controller; import org.junit.jupiter.api.Test; import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.configs.ControllerTest; import org.runimo.runimo.user.UserFixtures; import org.runimo.runimo.user.service.UserFinder; import org.runimo.runimo.user.service.dtos.ItemQueryResponse; +import org.runimo.runimo.user.service.usecases.items.UseItemUsecase; import org.runimo.runimo.user.service.usecases.query.MyItemQueryUsecase; 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.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -22,9 +21,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") +@ControllerTest(controllers = {UserItemController.class}) class QueryItemControllerTest { @Autowired @@ -36,6 +33,8 @@ class QueryItemControllerTest { private UserFinder userFinder; @MockitoBean private MyItemQueryUsecase myItemQueryUsecase; + @MockitoBean + private UseItemUsecase useItemUsecase; @Test void 보유한_아이템_조회_성공() throws Exception { diff --git a/src/test/resources/sql/user_mypage_test_data.sql b/src/test/resources/sql/user_mypage_test_data.sql new file mode 100644 index 00000000..1abbb4b1 --- /dev/null +++ b/src/test/resources/sql/user_mypage_test_data.sql @@ -0,0 +1,31 @@ +-- 사용자 +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE users; +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, total_time_in_seconds, created_at, + updated_at) +VALUES (1, 'test-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, NOW(), NOW()); +SET FOREIGN_KEY_CHECKS = 1; + + +TRUNCATE TABLE items; +INSERT INTO items (name, item_code, description, item_type, img_url, dtype, egg_type, hatch_require_amount, created_at, + updated_at) +VALUES ('마당알', 'A100', '마당알: 기본 알', 'USABLE', 'example.url', 'EGG', 'MADANG', 10, NOW(), NOW()); + + +-- 보유 아이템 +TRUNCATE TABLE user_item; +INSERT INTO user_item (id, user_id, item_id, quantity, created_at, updated_at) +VALUES (1001, 1, 1, 2, NOW(), NOW()), + (1002, 1, 2, 1, NOW(), NOW()); + +TRUNCATE TABLE user_love_point; +INSERT INTO user_love_point (id, user_id, amount, created_at, updated_at) +VALUES (1001, 1, 0, NOW(), NOW()); + + +TRUNCATE TABLE running_records; +INSERT INTO running_records (id, user_id, record_public_id, title, started_at, end_at, total_distance, pace_in_milli_seconds, is_rewarded, created_at, updated_at) +VALUES (1, 1, 'record-public-id-1', 'record-title-1', '2025-03-20 13:00:00', '2025-03-20 13:00:00', 1234, 6666, false, NOW(), NOW()), + (2, 1, 'record-public-id-2', 'record-title-2', '2025-03-29 13:00:00', '2025-03-29 14:00:00', 2345, 6700, false, NOW(), NOW()); +