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 58274226..6625d8d0 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -24,4 +24,7 @@ Slice findFirstRunOfWeek( @Param("now") LocalDateTime now, Pageable pageable ); + + @Query("SELECT COUNT(r.id) FROM RunningRecord r WHERE r.userId = :id") + Long countByUserId(Long id); } 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 d0427814..b5b1e40f 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -34,4 +34,9 @@ public Optional findFirstRunOfCurrentWeek(Long userId) { PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("startedAt").ascending()); return recordRepository.findFirstRunOfWeek(userId, startOfWeek, now, pageRequest).stream().findFirst(); } + + @Transactional(readOnly = true) + public Long countByUserId(Long userId) { + return recordRepository.countByUserId(userId); + } } diff --git a/src/main/java/org/runimo/runimo/user/controller/MainViewController.java b/src/main/java/org/runimo/runimo/user/controller/MainViewController.java new file mode 100644 index 00000000..4b39f215 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/MainViewController.java @@ -0,0 +1,37 @@ +package org.runimo.runimo.user.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.service.usecases.MainViewQueryUsecase; +import org.runimo.runimo.user.service.dtos.MainViewResponse; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +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; + +@Tag(name = "MAIN VIEW") +@RestController +@RequestMapping("/api/v1/main") +@RequiredArgsConstructor +public class MainViewController { + + private final MainViewQueryUsecase mainViewQueryUsecase; + + + @Operation(summary = "메인 화면 조회", description = "메인 화면을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "메인 화면 조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping + public ResponseEntity> queryMainView( + @UserId Long userId) { + MainViewResponse response = mainViewQueryUsecase.execute(userId); + return ResponseEntity.ok(SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); + } +} diff --git a/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java index 592bafe6..9a35b264 100644 --- a/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java +++ b/src/main/java/org/runimo/runimo/user/repository/UserItemRepository.java @@ -10,6 +10,7 @@ import org.springframework.data.jpa.repository.QueryHints; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository @@ -27,4 +28,9 @@ public interface UserItemRepository extends JpaRepository { "join Egg e on ui.itemId = e.id " + "where ui.userId = :userId and e.eggType = :eggType") Optional findByUserIdAndEggType(Long userId, EggType eggType); + + @Query("select ui from UserItem ui " + + "join Egg e on ui.itemId = e.id " + + "where ui.userId = :userId") + List findAllEggsByUserId(Long userId); } diff --git a/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java b/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java index 4b9cea37..c2513ac6 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java +++ b/src/main/java/org/runimo/runimo/user/service/UserItemFinder.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Component @@ -15,6 +16,11 @@ public class UserItemFinder { private final UserItemRepository userItemRepository; + @Transactional(readOnly = true) + public List findEggsByUserId(Long userId) { + return userItemRepository.findAllEggsByUserId(userId); + } + @Transactional(readOnly = true) public Optional findEggByUserIdAndEggType(Long userId, EggType eggType) { return userItemRepository.findByUserIdAndEggType(userId, eggType); diff --git a/src/main/java/org/runimo/runimo/user/service/UserItemProcessor.java b/src/main/java/org/runimo/runimo/user/service/UserItemProcessor.java index f914f569..01a42c51 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserItemProcessor.java +++ b/src/main/java/org/runimo/runimo/user/service/UserItemProcessor.java @@ -15,7 +15,7 @@ public class UserItemProcessor { @Transactional public void updateItemQuantity(Long userId, Long itemId, Long amount) { - UserItem userItem = userItemFinder.findByUserIdAndItemId(userId, itemId) + UserItem userItem = userItemFinder.findByUserIdAndItemIdWithXLock(userId, itemId) .orElseThrow(IllegalStateException::new); userItem.gainItem(amount); userItemRepository.save(userItem); diff --git a/src/main/java/org/runimo/runimo/user/service/dtos/MainViewResponse.java b/src/main/java/org/runimo/runimo/user/service/dtos/MainViewResponse.java new file mode 100644 index 00000000..4ad0bfa6 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/dtos/MainViewResponse.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.user.service.dtos; + +public record MainViewResponse( + String nickname, + String profileImageUrl, + Long lovePoint, + Long totalDistanceInMeters, + Long totalRunningCount, + Long totalEggCount +) { +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecase.java b/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecase.java new file mode 100644 index 00000000..b4651678 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.user.service.usecases; + +import org.runimo.runimo.user.service.dtos.MainViewResponse; + +public interface MainViewQueryUsecase { + + MainViewResponse execute(Long userId); +} diff --git a/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecaseImpl.java new file mode 100644 index 00000000..2459ac56 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/usecases/MainViewQueryUsecaseImpl.java @@ -0,0 +1,47 @@ +package org.runimo.runimo.user.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.domain.UserItem; +import org.runimo.runimo.user.service.UserFinder; +import org.runimo.runimo.user.service.UserItemFinder; +import org.runimo.runimo.user.service.dtos.MainViewResponse; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MainViewQueryUsecaseImpl implements MainViewQueryUsecase { + + private final UserFinder userFinder; + private final UserItemFinder userItemFinder; + private final RecordFinder recordFinder; + + @Override + public MainViewResponse execute(Long userId) { + // 유저 달리기 스탯 + User user = userFinder.findUserById(userId) + .orElseThrow(NoSuchElementException::new); + List userEggs = userItemFinder.findEggsByUserId(userId); + Long eggCount = userEggs.stream() + .map(UserItem::getQuantity) + .collect(Collectors.summarizingLong(Long::longValue)) + .getSum(); + Long runningCount = recordFinder.countByUserId(userId); + Long lovePoint = userFinder.findLovePointByUserId(userId) + .orElseThrow(NoSuchElementException::new) + .getAmount(); + return new MainViewResponse( + user.getNickname(), + user.getImgUrl(), + lovePoint, + user.getTotalDistanceInMeters(), + runningCount, + eggCount + ); + } +} diff --git a/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java b/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.java new file mode 100644 index 00000000..d1e67bd4 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/MainViewAcceptanceTest.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.test.context.ActiveProfiles; +import org.springframework.test.context.jdbc.Sql; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class MainViewAcceptanceTest { + + @LocalServerPort + int port; + + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Autowired + private CleanUpUtil cleanUpUtil; + + private static final String USER_UUID = "test-user-uuid-1"; + private static final String AUTH_HEADER_PREFIX = "Bearer "; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + @Test + @Sql(scripts = "/sql/main_view_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 메인화면_조회_성공시_정확한_정보를_반환한다() { + // given + String token = AUTH_HEADER_PREFIX + jwtTokenFactory.generateAccessToken(USER_UUID); + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .when() + .get("/api/v1/main") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("code", equalTo("USH2001")) + .body("payload.nickname", equalTo("Daniel")) + .body("payload.profile_image_url", equalTo("https://example.com/images/user1.png")) + .body("payload.total_running_count", equalTo(2)) + .body("payload.total_distance_in_meters", equalTo(3000)) + .body("payload.love_point", equalTo(100)) + .body("payload.total_egg_count", equalTo(3)); + } +} diff --git a/src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java b/src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java new file mode 100644 index 00000000..d5aac688 --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/MainViewControllerTest.java @@ -0,0 +1,69 @@ +package org.runimo.runimo.user.api; + +import org.junit.jupiter.api.Test; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.user.service.usecases.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; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class MainViewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserFinder userFinder; + @MockitoBean + private MainViewQueryUsecase mainViewUsecase; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Test + void 메인_화면_조회_성공() throws Exception { + // given + + String accessToken = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + + MainViewResponse response = new MainViewResponse( + "Daniel", + "https://example.com/images/user1.png", + 2L, + 3000L, + 100L, + 3L + ); + given(mainViewUsecase.execute(any())).willReturn(response); + given(userFinder.findUserByPublicId(any())).willReturn(Optional.ofNullable(UserFixtures.getDefaultUser())); + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/v1/main") + .header("Authorization", accessToken) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value("USH2001")) + .andExpect(jsonPath("$.payload.nickname").value("Daniel")) + .andExpect(jsonPath("$.payload.profile_image_url").value("https://example.com/images/user1.png")) + .andExpect(jsonPath("$.payload.total_running_count").value(100)) + .andExpect(jsonPath("$.payload.total_distance_in_meters").value(3000)) + .andExpect(jsonPath("$.payload.love_point").value(2)) + .andExpect(jsonPath("$.payload.total_egg_count").value(3)); + } +} diff --git a/src/test/resources/sql/main_view_data.sql b/src/test/resources/sql/main_view_data.sql new file mode 100644 index 00000000..409cf976 --- /dev/null +++ b/src/test/resources/sql/main_view_data.sql @@ -0,0 +1,32 @@ +-- 사용자 +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', 3000, 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()); + +INSERT INTO items (name, item_code, description, item_type, img_url, dtype, egg_type, hatch_require_amount, created_at, + updated_at) +VALUES ('숲알', 'A101', '숲알: 기본 알', 'USABLE', 'example1.url', 'EGG', 'FOREST', 20, 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', NOW(), NOW(), 1000, 100, false, NOW(), NOW()), + (2, 1, 'record-public-id-2', 'record-title-2', NOW(), NOW(), 2000, 200, false, 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, 100, NOW(), NOW()); \ No newline at end of file