diff --git a/src/main/java/org/runimo/runimo/records/controller/RecordController.java b/src/main/java/org/runimo/runimo/records/controller/RecordController.java index 813e6588..023e713b 100644 --- a/src/main/java/org/runimo/runimo/records/controller/RecordController.java +++ b/src/main/java/org/runimo/runimo/records/controller/RecordController.java @@ -18,10 +18,14 @@ import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; import org.runimo.runimo.records.service.usecases.dtos.RecordSaveResponse; import org.runimo.runimo.user.controller.UserId; +import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; +import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; +import org.runimo.runimo.user.enums.UserHttpResponseCode; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.net.URI; +import java.time.LocalDate; @Tag(name = "RECORD", description = "기록 관련 API") @RequestMapping("/api/v1/records") @@ -81,4 +85,22 @@ public ResponseEntity updateRecord( recordUpdateUsecase.updateRecord(RecordUpdateRequest.toCommand(userId, request)); return ResponseEntity.ok().build(); } + + @Operation(summary = "주간 기록 통계 조회" , description = "요청한 기간의 주간 기록 통계를 조회") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "주간 기록 통계 조회 성공", + content = @Content(schema = @Schema(implementation = WeeklyRecordStatResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + } + ) + @GetMapping("/stats/weekly") + public ResponseEntity> queryWeeklyRecordStat( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @UserId Long userId) { + WeeklyRecordStatResponse response = recordQueryUsecase.getUserWeeklyRecordStat(new WeeklyStatQuery(userId, startDate, endDate)); + return ResponseEntity.ok(SuccessResponse.of(UserHttpResponseCode.MY_PAGE_DATA_FETCHED, response)); + } } 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 a7523407..5b397921 100644 --- a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java +++ b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java @@ -1,7 +1,6 @@ package org.runimo.runimo.records.domain; -import com.fasterxml.jackson.annotation.JsonUnwrapped; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -93,6 +92,7 @@ public Distance getTotalDistance() { 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 c1922255..0aae89e1 100644 --- a/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java +++ b/src/main/java/org/runimo/runimo/records/domain/SegmentPaceConverter.java @@ -7,9 +7,9 @@ import java.util.List; -public class SegmentPaceConverter implements AttributeConverter, String> -{ - private ObjectMapper objectMapper = new ObjectMapper(); +public class SegmentPaceConverter implements AttributeConverter, String> { + private final ObjectMapper objectMapper = new ObjectMapper(); + @Override public String convertToDatabaseColumn(List segmentPaces) { try { @@ -21,9 +21,10 @@ public String convertToDatabaseColumn(List segmentPaces) { @Override public List convertToEntityAttribute(String s) { - TypeReference> typeRef = new TypeReference>() {}; + TypeReference> typeRef = new TypeReference>() { + }; try { - if(s == null || s.isEmpty()) { + if (s == null || s.isEmpty()) { return List.of(); } return objectMapper.readValue(s, typeRef); diff --git a/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java index 25565227..d65813b6 100644 --- a/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java +++ b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java @@ -1,4 +1,5 @@ package org.runimo.runimo.records.enums; + import org.runimo.runimo.exceptions.code.CustomResponseCode; import org.springframework.http.HttpStatus; 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 e870bcf9..495d01d4 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -1,6 +1,7 @@ package org.runimo.runimo.records.repository; import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.service.dtos.RunningRecordDistance; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,6 +10,7 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository @@ -30,4 +32,14 @@ Slice findFirstRunOfWeek( @Query("select r from RunningRecord r where r.userId = :userId") Slice findLatestByUserId(Long userId, Pageable pageRequest); + + @Query("select new org.runimo.runimo.records.service.dtos.RunningRecordDistance(" + + "sum(r.totalDistance.amount), " + + "cast(r.startedAt as localdate)) " + + "from RunningRecord r " + + "where r.userId = :userId " + + "and r.startedAt between :startOfWeek and :now " + + "group by function('date', r.startedAt)") + List findDailyDistanceByUserIdAndThisWeek(Long userId, LocalDateTime startOfWeek, LocalDateTime now); + } 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 6c3ae7e5..051cbaef 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -3,12 +3,14 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.repository.RecordRepository; +import org.runimo.runimo.records.service.dtos.RunningRecordDistance; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Component @@ -45,4 +47,9 @@ public Optional findLatestRunningRecordByUserId(Long userId) { PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("startedAt").descending()); return recordRepository.findLatestByUserId(userId, pageRequest).stream().findFirst(); } + + @Transactional(readOnly = true) + public List findDailyStatByUserIdBetween(Long id, LocalDateTime from, LocalDateTime to) { + return recordRepository.findDailyDistanceByUserIdAndThisWeek(id, from, to); + } } diff --git a/src/main/java/org/runimo/runimo/records/service/dtos/DailyStat.java b/src/main/java/org/runimo/runimo/records/service/dtos/DailyStat.java new file mode 100644 index 00000000..b6145cfd --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/dtos/DailyStat.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.records.service.dtos; + + +import java.time.LocalDate; + +public record DailyStat( + LocalDate date, + Long distance +) { +} + diff --git a/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java b/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java new file mode 100644 index 00000000..4cb695f4 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/dtos/RunningRecordDistance.java @@ -0,0 +1,16 @@ +package org.runimo.runimo.records.service.dtos; + +import java.time.DayOfWeek; +import java.time.LocalDate; + +public record RunningRecordDistance( + Long totalDistanceInMeters, + LocalDate date +) { + public DayOfWeek getDayOfWeek() { + return date.getDayOfWeek(); + } + public DailyStat toDailyStat() { + return new DailyStat(date, totalDistanceInMeters); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyRecordStatResponse.java b/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyRecordStatResponse.java new file mode 100644 index 00000000..3a99a09c --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyRecordStatResponse.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.records.service.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(name = "주간 통계 응답") +public record WeeklyRecordStatResponse( + List dailyStats +) { +} + diff --git a/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyStatQuery.java b/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyStatQuery.java new file mode 100644 index 00000000..db673b34 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/dtos/WeeklyStatQuery.java @@ -0,0 +1,6 @@ +package org.runimo.runimo.records.service.dtos; + +import java.time.LocalDate; + +public record WeeklyStatQuery(Long userId, LocalDate startDate, LocalDate endDate) { +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java index 4f82344e..3745f3ec 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java @@ -1,7 +1,10 @@ package org.runimo.runimo.records.service.usecases; import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; +import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; +import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; public interface RecordQueryUsecase { RecordDetailViewResponse getRecordDetailView(Long publicId); + WeeklyRecordStatResponse getUserWeeklyRecordStat(WeeklyStatQuery query); } diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java index 2f0c990c..051f933f 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java @@ -3,10 +3,16 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.records.service.dtos.RunningRecordDistance; import org.runimo.runimo.records.service.usecases.dtos.RecordDetailViewResponse; +import org.runimo.runimo.records.service.dtos.DailyStat; +import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; +import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; import org.springframework.stereotype.Service; -import java.util.NoSuchElementException; +import java.time.DayOfWeek; +import java.util.*; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -20,4 +26,31 @@ public RecordDetailViewResponse getRecordDetailView(Long recordId) { .orElseThrow(NoSuchElementException::new); return RecordDetailViewResponse.from(runningRecord); } + + @Override + public WeeklyRecordStatResponse getUserWeeklyRecordStat(WeeklyStatQuery query) { + // DB에서 일별로 이미 합산된 데이터 조회 + List dailyDistances = recordFinder.findDailyStatByUserIdBetween( + query.userId(), + query.startDate().atStartOfDay(), + query.endDate().atTime(23, 59, 59) + ); + + // 결과를 DayOfWeek 맵으로 변환 + Map dailyStatsMap = dailyDistances.stream() + .collect(Collectors.toMap( + RunningRecordDistance::getDayOfWeek, + RunningRecordDistance::toDailyStat + )); + + // 월요일부터 일요일까지 순서대로 일간 데이터 정리 + List weeklyStats = Arrays.stream(DayOfWeek.values()) + .map(day -> dailyStatsMap.getOrDefault(day, new DailyStat( + query.startDate().with(day), + 0L + ))) + .toList(); + + return new WeeklyRecordStatResponse(weeklyStats); + } } diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index c9d6b710..fda95cac 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -23,18 +23,18 @@ CREATE TABLE `users` `img_url` VARCHAR(255), `total_distance_in_meters` BIGINT NOT NULL DEFAULT 0, `total_time_in_seconds` BIGINT NOT NULL DEFAULT 0, - `updated_at` TIMESTAMP, - `created_at` TIMESTAMP, - `deleted_at` TIMESTAMP + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_token` ( `user_id` BIGINT NOT NULL, `device_token` VARCHAR(255) NOT NULL, - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); @@ -43,10 +43,10 @@ CREATE TABLE `user_love_point` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, - `amount` BIGINT NOT NULL DEFAULT 0, - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP + `amount` BIGINT NOT NULL DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `oauth_account` @@ -55,103 +55,101 @@ CREATE TABLE `oauth_account` `user_id` BIGINT NOT NULL, `provider` VARCHAR(255), `provider_id` VARCHAR(255), - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); - CREATE TABLE `running_record` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, `record_public_id` VARCHAR(255) NOT NULL, - `title` varchar(255), - `started_at` timestamp, - `end_at` timestamp, - `total_distance` integer, - `pace_in_milli_seconds` integer, - `is_rewarded` boolean, + `title` VARCHAR(255), + `started_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `end_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `total_distance` INTEGER, + `pace_in_milli_seconds` INTEGER, + `is_rewarded` BOOLEAN, `pace_per_km` VARCHAR(10000), - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); -CREATE TABLE item +CREATE TABLE `item` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `name` varchar(255) NOT NULL, - `item_code` varchar(255) NOT NULL, - `description` varchar(255), - `item_type` varchar(255) NOT NULL, - `img_url` varchar(255), - `dtype` varchar(255), - `egg_type` varchar(255), - `hatch_require_amount` long, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `item_code` VARCHAR(255) NOT NULL, + `description` VARCHAR(255), + `item_type` VARCHAR(255) NOT NULL, + `img_url` VARCHAR(255), + `dtype` VARCHAR(255), + `egg_type` VARCHAR(255), + `hatch_require_amount` BIGINT, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `item_activity` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `activity_user_id` integer NOT NULL, - `activity_item_id` integer NOT NULL, - `activity_event_type` varchar(255) NOT NULL, - `quantity` integer, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `activity_user_id` INTEGER NOT NULL, + `activity_item_id` INTEGER NOT NULL, + `activity_event_type` VARCHAR(255) NOT NULL, + `quantity` INTEGER, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, - `item_id` integer NOT NULL, - `quantity` integer NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `user_id` INTEGER NOT NULL, + `item_id` INTEGER NOT NULL, + `quantity` INTEGER NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `incubating_egg` ( - `id` - BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, - `egg_id` integer NOT NULL, - `current_love_point_amount` integer, - `hatch_require_amount` integer, - `egg_status` varchar(255), - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `egg_id` INTEGER NOT NULL, + `current_love_point_amount` INTEGER, + `hatch_require_amount` INTEGER, + `egg_status` VARCHAR(255), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `runimo` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `name` varchar(255), - `code` varchar(255), - `description` varchar(255), - `type` varchar(255) NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` timestamp + `name` VARCHAR(255), + `code` VARCHAR(255), + `description` VARCHAR(255), + `type` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_runimo` ( - `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` BIGINT NOT NULL, - `runimo_id` BIGINT NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` timestamp + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `runimo_id` BIGINT NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); ALTER TABLE `user_token` diff --git a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java index a03080e5..a6cf6e64 100644 --- a/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java +++ b/src/test/java/org/runimo/runimo/records/api/RecordAcceptanceTest.java @@ -13,6 +13,7 @@ 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.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.jdbc.Sql; @@ -31,7 +32,8 @@ class RecordAcceptanceTest { @Autowired private JwtTokenFactory jwtTokenFactory; - + private static final String USER_UUID = "test-user-uuid-1"; + private static final String AUTH_HEADER_PREFIX = "Bearer "; @Autowired private ObjectMapper objectMapper; @Autowired @@ -132,5 +134,77 @@ void tearDown() { .body("payload.segment_pace_list[0].pace", equalTo(732000)); } + + + @Test + @WithMockUser(username = USER_UUID) + @Sql(scripts = "/sql/weekly_record_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) + .param("startDate", "2025-03-31") + .param("endDate", "2025-04-06") + .when() + .get("/api/v1/records/stats/weekly") + .then() + .log().all() + .statusCode(200) + .body("code", equalTo("USH2001")) + .body("payload.daily_stats.size()", equalTo(7)) + .body("payload.daily_stats[0].date", equalTo("2025-03-31")) + .body("payload.daily_stats[0].distance", equalTo(1000)) + .body("payload.daily_stats[1].date", equalTo("2025-04-01")) + .body("payload.daily_stats[1].distance", equalTo(2000)) + .body("payload.daily_stats[2].date", equalTo("2025-04-02")) + .body("payload.daily_stats[2].distance", equalTo(3000)) + .body("payload.daily_stats[3].date", equalTo("2025-04-03")) + .body("payload.daily_stats[3].distance", equalTo(4000)) + .body("payload.daily_stats[4].date", equalTo("2025-04-04")) + .body("payload.daily_stats[4].distance", equalTo(5000)) + .body("payload.daily_stats[5].date", equalTo("2025-04-05")) + .body("payload.daily_stats[5].distance", equalTo(6000)) + .body("payload.daily_stats[6].date", equalTo("2025-04-06")) + .body("payload.daily_stats[6].distance", equalTo(7000)); + } + + @Test + @Sql(scripts = "/sql/weekly_record_partial_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 주간_달리기_거리_조회_성공시_일부_날짜에_데이터가_없을때_0을_반환한다() { + // given + String token = AUTH_HEADER_PREFIX + jwtTokenFactory.generateAccessToken(USER_UUID); + + // when & then + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + .param("startDate", "2025-03-31") + .param("endDate", "2025-04-06") + .when() + .get("/api/v1/records/stats/weekly") + .then() + .log().ifValidationFails() + .statusCode(200) + .body("code", equalTo("USH2001")) + .body("payload.daily_stats.size()", equalTo(7)) + .body("payload.daily_stats[0].date", equalTo("2025-03-31")) + .body("payload.daily_stats[0].distance", equalTo(1000)) + .body("payload.daily_stats[1].date", equalTo("2025-04-01")) + .body("payload.daily_stats[1].distance", equalTo(2000)) + .body("payload.daily_stats[2].date", equalTo("2025-04-02")) + .body("payload.daily_stats[2].distance", equalTo(0)) + .body("payload.daily_stats[3].date", equalTo("2025-04-03")) + .body("payload.daily_stats[3].distance", equalTo(0)) + .body("payload.daily_stats[4].date", equalTo("2025-04-04")) + .body("payload.daily_stats[4].distance", equalTo(5000)) + .body("payload.daily_stats[5].date", equalTo("2025-04-05")) + .body("payload.daily_stats[5].distance", equalTo(6000)) + .body("payload.daily_stats[6].date", equalTo("2025-04-06")) + .body("payload.daily_stats[6].distance", equalTo(7000)); + } } diff --git a/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java b/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java new file mode 100644 index 00000000..a8370b25 --- /dev/null +++ b/src/test/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImplTest.java @@ -0,0 +1,114 @@ +package org.runimo.runimo.records.service.usecases; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.records.service.dtos.RunningRecordDistance; +import org.runimo.runimo.records.service.dtos.DailyStat; +import org.runimo.runimo.records.service.dtos.WeeklyRecordStatResponse; +import org.runimo.runimo.records.service.dtos.WeeklyStatQuery; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +class RecordQueryUsecaseImplTest { + + @Mock + private RecordFinder recordFinder; + + private RecordQueryUsecase queryUsecase; + + @BeforeEach + void setUp() { + openMocks(this); + queryUsecase = new RecordQueryUsecaseImpl(recordFinder); + } + + @Test + void 주간_일별_달리기_거리_합() { + // given + LocalDateTime now = LocalDateTime.of(2025, 4, 6, 10, 0); + LocalDateTime startOfWeek = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0); + + List savedData = List.of( + new RunningRecordDistance(1000L, LocalDate.of(2025, 3, 31)), // 월 + new RunningRecordDistance(2000L, LocalDate.of(2025, 4, 1)), // 화 + new RunningRecordDistance(3000L, LocalDate.of(2025, 4, 2)), // 수 + new RunningRecordDistance(4000L, LocalDate.of(2025, 4, 3)), // 목 + new RunningRecordDistance(5000L, LocalDate.of(2025, 4, 4)), // 금 + new RunningRecordDistance(6000L, LocalDate.of(2025, 4, 5)), // 토 + new RunningRecordDistance(7000L, LocalDate.of(2025, 4, 6)) // 일 + ); + + when(recordFinder.findDailyStatByUserIdBetween(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(savedData); + + // when + WeeklyRecordStatResponse response = queryUsecase.getUserWeeklyRecordStat(new WeeklyStatQuery( + 1L, + LocalDate.of(2025, 3, 31), + LocalDate.of(2025, 4, 6) + )); + + // then + List expectedDistances = List.of( + new DailyStat(LocalDate.of(2025, 3, 31), 1000L), + new DailyStat(LocalDate.of(2025, 4, 1), 2000L), + new DailyStat(LocalDate.of(2025, 4, 2), 3000L), + new DailyStat(LocalDate.of(2025, 4, 3), 4000L), + new DailyStat(LocalDate.of(2025, 4, 4), 5000L), + new DailyStat(LocalDate.of(2025, 4, 5), 6000L), + new DailyStat(LocalDate.of(2025, 4, 6), 7000L) + ); + + assertEquals(expectedDistances, response.dailyStats()); + } + + @Test + void 주간_일별_달리기_거리_합_데이터가_없는_요일이_있을때() { + // given + LocalDateTime now = LocalDateTime.of(2025, 4, 6, 10, 0); + LocalDateTime startOfWeek = now.with(DayOfWeek.MONDAY).withHour(0).withMinute(0).withSecond(0); + + List savedData = List.of( + new RunningRecordDistance(1000L, LocalDate.of(2025, 3, 31)), // 월 + new RunningRecordDistance(9000L, LocalDate.of(2025, 4, 1)), // 화 + // 수, 목 데이터 없음 + new RunningRecordDistance(5000L, LocalDate.of(2025, 4, 4)), // 금 + new RunningRecordDistance(6000L, LocalDate.of(2025, 4, 5)), // 토 + new RunningRecordDistance(7000L, LocalDate.of(2025, 4, 6)) // 일 + ); + + when(recordFinder.findDailyStatByUserIdBetween(eq(1L), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(savedData); + + // when + WeeklyRecordStatResponse response = queryUsecase.getUserWeeklyRecordStat(new WeeklyStatQuery( + 1L, + LocalDate.of(2025, 3, 31), + LocalDate.of(2025, 4, 6) + )); + + // then + List expectedDistances = List.of( + new DailyStat(LocalDate.of(2025, 3, 31), 1000L), + new DailyStat(LocalDate.of(2025, 4, 1), 9000L), + new DailyStat(LocalDate.of(2025, 4, 2), 0L), // 데이터 없음 + new DailyStat(LocalDate.of(2025, 4, 3), 0L), // 데이터 없음 + new DailyStat(LocalDate.of(2025, 4, 4), 5000L), + new DailyStat(LocalDate.of(2025, 4, 5), 6000L), + new DailyStat(LocalDate.of(2025, 4, 6), 7000L) + ); + + assertEquals(expectedDistances, response.dailyStats()); + } +} \ No newline at end of file diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index f3f79773..fda95cac 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -23,18 +23,18 @@ CREATE TABLE `users` `img_url` VARCHAR(255), `total_distance_in_meters` BIGINT NOT NULL DEFAULT 0, `total_time_in_seconds` BIGINT NOT NULL DEFAULT 0, - `updated_at` TIMESTAMP, - `created_at` TIMESTAMP, - `deleted_at` TIMESTAMP + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_token` ( `user_id` BIGINT NOT NULL, `device_token` VARCHAR(255) NOT NULL, - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); @@ -43,10 +43,10 @@ CREATE TABLE `user_love_point` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, - `amount` BIGINT NOT NULL DEFAULT 0, - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP + `amount` BIGINT NOT NULL DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `oauth_account` @@ -55,104 +55,101 @@ CREATE TABLE `oauth_account` `user_id` BIGINT NOT NULL, `provider` VARCHAR(255), `provider_id` VARCHAR(255), - `created_at` TIMESTAMP, - `updated_at` TIMESTAMP, - `deleted_at` TIMESTAMP, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL, FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ); - CREATE TABLE `running_record` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, `record_public_id` VARCHAR(255) NOT NULL, - `title` varchar(255), - `started_at` timestamp, - `end_at` timestamp, - `total_distance` integer, - `pace_in_milli_seconds` integer, - `is_rewarded` boolean, + `title` VARCHAR(255), + `started_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `end_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `total_distance` INTEGER, + `pace_in_milli_seconds` INTEGER, + `is_rewarded` BOOLEAN, `pace_per_km` VARCHAR(10000), - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `item` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `name` varchar(255) NOT NULL, - `item_code` varchar(255) NOT NULL, - `description` varchar(255), - `item_type` varchar(255) NOT NULL, - `img_url` varchar(255), - `dtype` varchar(255), - `egg_type` varchar(255), - `hatch_require_amount` long, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `item_code` VARCHAR(255) NOT NULL, + `description` VARCHAR(255), + `item_type` VARCHAR(255) NOT NULL, + `img_url` VARCHAR(255), + `dtype` VARCHAR(255), + `egg_type` VARCHAR(255), + `hatch_require_amount` BIGINT, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `item_activity` ( - `id` integer PRIMARY KEY AUTO_INCREMENT, - `activity_user_id` integer NOT NULL, - `activity_item_id` integer NOT NULL, - `activity_event_type` varchar(255) NOT NULL, - `quantity` integer, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` INTEGER PRIMARY KEY AUTO_INCREMENT, + `activity_user_id` INTEGER NOT NULL, + `activity_item_id` INTEGER NOT NULL, + `activity_event_type` VARCHAR(255) NOT NULL, + `quantity` INTEGER, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, - `item_id` integer NOT NULL, - `quantity` integer NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `user_id` INTEGER NOT NULL, + `item_id` INTEGER NOT NULL, + `quantity` INTEGER NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `incubating_egg` ( - `id` - BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` integer NOT NULL, - `egg_id` integer NOT NULL, - `current_love_point_amount` integer, - `hatch_require_amount` integer, - `egg_status` varchar(255), - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` TIMESTAMP + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` INTEGER NOT NULL, + `egg_id` INTEGER NOT NULL, + `current_love_point_amount` INTEGER, + `hatch_require_amount` INTEGER, + `egg_status` VARCHAR(255), + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); - CREATE TABLE `runimo` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `name` varchar(255), - `code` varchar(255), - `description` varchar(255), - `type` varchar(255) NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` timestamp + `name` VARCHAR(255), + `code` VARCHAR(255), + `description` VARCHAR(255), + `type` VARCHAR(255) NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); CREATE TABLE `user_runimo` ( - `id` BIGINT PRIMARY KEY AUTO_INCREMENT, - `user_id` BIGINT NOT NULL, - `runimo_id` BIGINT NOT NULL, - `created_at` timestamp, - `updated_at` timestamp, - `deleted_at` timestamp + `id` BIGINT PRIMARY KEY AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `runimo_id` BIGINT NOT NULL, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted_at` TIMESTAMP NULL ); ALTER TABLE `user_token` diff --git a/src/test/resources/sql/weekly_record_data.sql b/src/test/resources/sql/weekly_record_data.sql new file mode 100644 index 00000000..ba08174b --- /dev/null +++ b/src/test/resources/sql/weekly_record_data.sql @@ -0,0 +1,21 @@ +-- 사용자 +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 running_record; + +INSERT INTO running_record (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-31 10:00:00', '2025-03-31 11:00:00', 1000, 100, false, NOW(), NOW()), + (2, 1, 'record-public-id-2', 'record-title-2', '2025-04-01 10:00:00', '2025-04-01 11:00:00', 2000, 200, false, NOW(), NOW()), + (3, 1, 'record-public-id-3', 'record-title-3', '2025-04-02 10:00:00', '2025-04-02 11:00:00', 3000, 300, false, NOW(), NOW()), + (4, 1, 'record-public-id-4', 'record-title-4', '2025-04-03 10:00:00', '2025-04-03 11:00:00', 4000, 400, false, NOW(), NOW()), + (5, 1, 'record-public-id-5', 'record-title-5', '2025-04-04 10:00:00', '2025-04-04 11:00:00', 5000, 500, false, NOW(), NOW()), + (6, 1, 'record-public-id-6', 'record-title-6', '2025-04-05 10:00:00', '2025-04-05 11:00:00', 6000, 600, false, NOW(), NOW()), + (7, 1, 'record-public-id-7', 'record-title-7', '2025-04-06 10:00:00', '2025-04-06 11:00:00', 7000, 700, false, NOW(), NOW()); diff --git a/src/test/resources/sql/weekly_record_partial_data.sql b/src/test/resources/sql/weekly_record_partial_data.sql new file mode 100644 index 00000000..d5d7203e --- /dev/null +++ b/src/test/resources/sql/weekly_record_partial_data.sql @@ -0,0 +1,19 @@ +-- 사용자 +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 running_record; + +INSERT INTO running_record (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-31 10:00:00', '2025-03-31 11:00:00', 1000, 100, false, NOW(), NOW()), + (2, 1, 'record-public-id-2', 'record-title-2', '2025-04-01 10:00:00', '2025-04-01 11:00:00', 2000, 200, false, NOW(), NOW()), + (3, 1, 'record-public-id-3', 'record-title-3', '2025-04-04 10:00:00', '2025-04-04 11:00:00', 5000, 500, false, NOW(), NOW()), + (4, 1, 'record-public-id-4', 'record-title-4', '2025-04-05 10:00:00', '2025-04-05 11:00:00', 6000, 600, false, NOW(), NOW()), + (5, 1, 'record-public-id-5', 'record-title-5', '2025-04-06 10:00:00', '2025-04-06 11:00:00', 7000, 700, false, NOW(), NOW());