diff --git a/src/main/java/com/dnd/runus/application/running/RunningRecordService.java b/src/main/java/com/dnd/runus/application/running/RunningRecordService.java index d3146fad..d43b9537 100644 --- a/src/main/java/com/dnd/runus/application/running/RunningRecordService.java +++ b/src/main/java/com/dnd/runus/application/running/RunningRecordService.java @@ -1,5 +1,6 @@ package com.dnd.runus.application.running; +import com.dnd.runus.application.running.dto.RunningResultDto; import com.dnd.runus.application.running.event.RunningRecordAddedEvent; import com.dnd.runus.domain.challenge.Challenge; import com.dnd.runus.domain.challenge.ChallengeRepository; @@ -23,6 +24,9 @@ import com.dnd.runus.presentation.v1.running.dto.request.RunningRecordRequestV1; import com.dnd.runus.presentation.v1.running.dto.request.RunningRecordWeeklySummaryType; import com.dnd.runus.presentation.v1.running.dto.response.*; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2.ChallengeAchievedDto; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2.GoalAchievedDto; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -35,6 +39,7 @@ import java.util.List; import java.util.Locale; import java.util.stream.Collectors; +import java.util.stream.Stream; import static com.dnd.runus.global.constant.MetricsConversionFactor.METERS_IN_A_KILOMETER; import static com.dnd.runus.global.constant.MetricsConversionFactor.SECONDS_PER_HOUR; @@ -180,6 +185,71 @@ public RunningRecordMonthlySummaryResponse getMonthlyRunningSummery(long memberI .build(); } + @Transactional + public RunningResultDto addRunningRecordV2(long memberId, RunningRecordRequestV2 request) { + Member member = + memberRepository.findById(memberId).orElseThrow(() -> new NotFoundException(Member.class, memberId)); + + List route = request.runningData().route().stream() + .flatMap(point -> Stream.of( + new CoordinatePoint( + point.start().longitude(), point.start().latitude()), + new CoordinatePoint(point.end().longitude(), point.end().latitude()))) + .collect(Collectors.toList()); + + // 러닝 record 저장 + RunningRecord record = runningRecordRepository.save(RunningRecord.builder() + .member(member) + .startAt(request.startAt().atZone(defaultZoneOffset)) + .endAt(request.endAt().atZone(defaultZoneOffset)) + .emoji(request.emotion()) + .startLocation(request.startLocation()) + .endLocation(request.endLocation()) + .distanceMeter(request.runningData().distanceMeter()) + .duration(request.runningData().runningTime()) + .calorie(request.runningData().calorie()) + .averagePace(Pace.from( + request.runningData().distanceMeter(), + request.runningData().runningTime())) + .route(route) + .build()); + + OffsetDateTime now = OffsetDateTime.now(); + int totalDistance = runningRecordRepository.findTotalDistanceMeterByMemberId(memberId); + Duration totalDuration = runningRecordRepository.findTotalDurationByMemberId(memberId, BASE_TIME, now); + + // 멤버 레벨, 뱃지, 지구한바퀴 저장(update) 이벤트 발생 + eventPublisher.publishEvent(new RunningRecordAddedEvent(member, record, totalDistance, totalDuration)); + + switch (request.achievementMode()) { + case CHALLENGE -> { + ChallengeAchievedDto challengeAchievedForAdd = request.challengeValues(); + Challenge challenge = challengeRepository + .findById(challengeAchievedForAdd.challengeId()) + .orElseThrow( + () -> new NotFoundException(Challenge.class, challengeAchievedForAdd.challengeId())); + + ChallengeAchievement challengeAchievement = challengeAchievementRepository.save( + new ChallengeAchievement(challenge, record, challengeAchievedForAdd.isSuccess())); + + return RunningResultDto.of(record, challengeAchievement); + } + case GOAL -> { + GoalAchievedDto goalAchievedForAdd = request.goalValues(); + GoalAchievement goalAchievement = goalAchievementRepository.save(new GoalAchievement( + record, + (goalAchievedForAdd.goalDistance() != null) ? GoalMetricType.DISTANCE : GoalMetricType.TIME, + (goalAchievedForAdd.goalDistance() != null) + ? goalAchievedForAdd.goalDistance() + : goalAchievedForAdd.goalTime(), + goalAchievedForAdd.isSuccess())); + + return RunningResultDto.of(record, goalAchievement); + } + } + return RunningResultDto.from(record); + } + @Transactional public RunningRecordAddResultResponseV1 addRunningRecordV1(long memberId, RunningRecordRequestV1 request) { Member member = diff --git a/src/main/java/com/dnd/runus/application/running/dto/RunningResultDto.java b/src/main/java/com/dnd/runus/application/running/dto/RunningResultDto.java new file mode 100644 index 00000000..d410507b --- /dev/null +++ b/src/main/java/com/dnd/runus/application/running/dto/RunningResultDto.java @@ -0,0 +1,41 @@ +package com.dnd.runus.application.running.dto; + + +import com.dnd.runus.domain.challenge.achievement.ChallengeAchievement; +import com.dnd.runus.domain.goalAchievement.GoalAchievement; +import com.dnd.runus.domain.running.RunningRecord; + +public record RunningResultDto( + RunningRecord runningRecord, + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode runningAchievementMode, + ChallengeAchievement challengeAchievement, + GoalAchievement goalAchievement +) { + public static RunningResultDto from(RunningRecord runningRecord) { + return new RunningResultDto( + runningRecord, + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.NORMAL, + null, + null + ); + } + + public static RunningResultDto of(RunningRecord runningRecord, + ChallengeAchievement challengeAchievement) { + return new RunningResultDto( + runningRecord, + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.CHALLENGE, + challengeAchievement, + null + ); + } + + public static RunningResultDto of(RunningRecord runningRecord, GoalAchievement goalAchievement) { + return new RunningResultDto( + runningRecord, + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.GOAL, + null, + goalAchievement + ); + } +} diff --git a/src/main/java/com/dnd/runus/domain/common/CoordinatePoint.java b/src/main/java/com/dnd/runus/domain/common/CoordinatePoint.java index 957a6cb7..14b59ea6 100644 --- a/src/main/java/com/dnd/runus/domain/common/CoordinatePoint.java +++ b/src/main/java/com/dnd/runus/domain/common/CoordinatePoint.java @@ -2,11 +2,19 @@ /** * @param longitude 경도 - * @param latitude 위도 - * @param altitude 고도 + * @param latitude 위도 + * @param altitude 고도 */ public record CoordinatePoint(double longitude, double latitude, double altitude) { + public CoordinatePoint(double longitude, double latitude) { - this(longitude, latitude, Double.NaN); + this(longitude, latitude, 0); + } + + /** + * null Island(longitude : 0, latitude: 0, altitude:0 인지점)을 확인 + */ + public boolean isNullIsland() { + return (this.longitude == 0 && this.latitude == 0); } } diff --git a/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java b/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java index f8163f29..310ebf4b 100644 --- a/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java +++ b/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java @@ -47,6 +47,8 @@ public enum ErrorType { CHALLENGE_MODE_WITH_PERSONAL_GOAL(BAD_REQUEST, DEBUG, "RUNNING_002", "챌린지 모드에서는 개인 목표를 설정할 수 없습니다"), GOAL_MODE_WITH_CHALLENGE_ID(BAD_REQUEST, DEBUG, "RUNNING_003", "개인 목표 모드에서는 챌린지 ID를 설정할 수 없습니다"), GOAL_TIME_AND_DISTANCE_BOTH_EXIST(BAD_REQUEST, DEBUG, "RUNNING_004", "개인 목표 시간과 거리 중 하나만 설정해야 합니다"), + GOAL_VALUES_REQUIRED_IN_GOAL_MODE(BAD_REQUEST, DEBUG, "RUNNING_005", "개인 목표 모드에서, 개인 목표 달성값은 필수 잆니다."), + CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE(BAD_REQUEST, DEBUG, "RUNNING_006", "챌린지 모드에서, 챌린지 달성값은 필수 입니다."), // WeatherErrorType WEATHER_API_ERROR(SERVICE_UNAVAILABLE, WARN, "WEATHER_001", "날씨 API 호출 중 오류가 발생했습니다"), diff --git a/src/main/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2.java b/src/main/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2.java index e5be37ea..5caf2f7f 100644 --- a/src/main/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2.java +++ b/src/main/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2.java @@ -2,13 +2,20 @@ import com.dnd.runus.application.running.RunningRecordService; import com.dnd.runus.application.running.RunningRecordServiceV2; +import com.dnd.runus.global.exception.type.ApiErrorType; +import com.dnd.runus.global.exception.type.ErrorType; import com.dnd.runus.presentation.annotation.MemberId; import com.dnd.runus.presentation.v1.running.dto.response.RunningRecordMonthlySummaryResponse; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2; import com.dnd.runus.presentation.v2.running.dto.response.RunningRecordMonthlySummaryResponseV2; +import com.dnd.runus.presentation.v2.running.dto.response.RunningRecordResultResponseV2; import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -33,4 +40,30 @@ public RunningRecordMonthlySummaryResponseV2 getMonthlyRunningSummary(@MemberId monthlyRunningSummery.monthlyTotalMeter(), runningRecordService2.getPercentageValues(memberId)); } + + @Operation( + summary = "러닝 기록 추가 API V2", + description = + """ + 러닝 기록을 추가합니다.
+ 러닝 기록은 시작 시간, 종료 시간, 러닝 평가(emotion), 러닝 데이터 등으로 구성됩니다.
+ 챌린지 모드가 normal : challengeValues, goalValues 둘다 null
+ 챌린지 모드가 challenge : challengeValues 필수 값
+ 챌린지 모드가 goal : goalValues 필수 값
+ 러닝 데이터는 위치, 거리, 시간, 칼로리, 평균 페이스, 러닝 경로로 구성됩니다.
+ 러닝 기록 추가에 성공하면 러닝 기록 ID, 기록 정보를 반환합니다.
+ """) + @ApiErrorType({ + ErrorType.START_AFTER_END, + ErrorType.CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE, + ErrorType.GOAL_VALUES_REQUIRED_IN_GOAL_MODE, + ErrorType.GOAL_TIME_AND_DISTANCE_BOTH_EXIST, + ErrorType.ROUTE_MUST_HAVE_AT_LEAST_TWO_COORDINATES + }) + @PostMapping + @ResponseStatus(HttpStatus.OK) + public RunningRecordResultResponseV2 addRunningRecord( + @MemberId long memberId, @Valid @RequestBody RunningRecordRequestV2 request) { + return RunningRecordResultResponseV2.from(runningRecordService.addRunningRecordV2(memberId, request)); + } } diff --git a/src/main/java/com/dnd/runus/presentation/v2/running/dto/RouteDtoV2.java b/src/main/java/com/dnd/runus/presentation/v2/running/dto/RouteDtoV2.java new file mode 100644 index 00000000..c4ce3256 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/v2/running/dto/RouteDtoV2.java @@ -0,0 +1,20 @@ +package com.dnd.runus.presentation.v2.running.dto; + + +import com.dnd.runus.domain.common.CoordinatePoint; + +/** + * 클라이언트와의 러닝 경로 요청/응답 형식 + * @param start 시작 위치 + * @param end 종료 위치 + */ +public record RouteDtoV2( + Point start, + Point end +) { + public record Point(double longitude, double latitude) { + public static Point from(CoordinatePoint point) { + return new Point(point.longitude(), point.longitude()); + } + } +} diff --git a/src/main/java/com/dnd/runus/presentation/v2/running/dto/request/RunningRecordRequestV2.java b/src/main/java/com/dnd/runus/presentation/v2/running/dto/request/RunningRecordRequestV2.java new file mode 100644 index 00000000..7265748a --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/v2/running/dto/request/RunningRecordRequestV2.java @@ -0,0 +1,98 @@ +package com.dnd.runus.presentation.v2.running.dto.request; + + +import com.dnd.runus.global.constant.RunningEmoji; +import com.dnd.runus.global.exception.BusinessException; +import com.dnd.runus.global.exception.type.ErrorType; +import com.dnd.runus.presentation.v2.running.dto.RouteDtoV2; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +public record RunningRecordRequestV2( + @NotNull + LocalDateTime startAt, + @NotNull + LocalDateTime endAt, + @NotBlank + @Schema(description = "시작 위치", example = "서울시 강남구") + String startLocation, + @NotBlank + @Schema(description = "종료 위치", example = "서울시 송파구") + String endLocation, + @NotNull + @Schema(description = "감정 표현, very-good: 최고, good: 좋음, soso: 보통, bad: 나쁨, very-bad: 최악") + RunningEmoji emotion, + @NotNull + @Schema(description = "목표 달성 모드, normal: 목표 설정X, challenge: 챌린지, goal: 개인 목표") + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode achievementMode, + @Schema(description = "챌린지 데이터, 챌린지를 하지 않은 경우 null이나 필드 없이 보내주세요") + ChallengeAchievedDto challengeValues, + @Schema(description = "목표 데이터, 목표를 설정하지 않은 경우 null이나 필드 없이 보내주세요") + GoalAchievedDto goalValues, + @NotNull + RunningRecordMetrics runningData +) { + public RunningRecordRequestV2 { + //request valid check + //시작, 종료 시간 유효값 확인 + if (startAt.isAfter(endAt)) { + throw new BusinessException(ErrorType.START_AFTER_END, startAt + " ~ " + endAt); + } + + // 러닝 모드 유요성 확인 + if (achievementMode == com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.CHALLENGE && challengeValues == null) { + throw new BusinessException(ErrorType.CHALLENGE_VALUES_REQUIRED_IN_CHALLENGE_MODE); + } + + if (achievementMode == com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.GOAL) { + if(goalValues == null) { + throw new BusinessException(ErrorType.GOAL_VALUES_REQUIRED_IN_GOAL_MODE); + } + if (goalValues.goalDistance() == null && goalValues.goalTime() == null) { + throw new BusinessException(ErrorType.GOAL_TIME_AND_DISTANCE_BOTH_EXIST); + } + } + + //러닝 경로 유요성 확인 + if(runningData.route() == null || runningData.route().size() < 2) { + throw new BusinessException(ErrorType.ROUTE_MUST_HAVE_AT_LEAST_TWO_COORDINATES); + } + } + + public record ChallengeAchievedDto( + @Schema(description = "챌린지 ID", example = "1") + long challengeId, + boolean isSuccess + ) { + } + + public record GoalAchievedDto( + @Schema(description = "개인 목표 거리 (미터), 거리 목표가 아닌 경우, null이나 필드 없이 보내주세요", example = "5000") + Integer goalDistance, + @Schema(description = "개인 목표 시간 (초), 시간 목표가 아닌 경우, null이나 필드 없이 보내주세요", example = "1800") + Integer goalTime, + boolean isSuccess + ) { + } + + @Schema(name = "RunningRecordMetrics for Add V2") + public record RunningRecordMetrics( + @NotNull + @Schema(description = "멈춘 시간을 제외한 실제로 달린 시간", example = "123:45:56", format = "HH:mm:ss") + Duration runningTime, + @Schema(description = "달린 거리(m)", example = "1000") + @NotNull + int distanceMeter, + @Schema(description = "소모 칼로리(kcal)", example = "100") + @NotNull + double calorie, + @NotNull + @Schema(description = "러닝 경로, 최소, 경로는 최소 2개의 좌표를 가져야합니다.") + List route + ) { + } +} diff --git a/src/main/java/com/dnd/runus/presentation/v2/running/dto/response/RunningRecordResultResponseV2.java b/src/main/java/com/dnd/runus/presentation/v2/running/dto/response/RunningRecordResultResponseV2.java new file mode 100644 index 00000000..57fd0841 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/v2/running/dto/response/RunningRecordResultResponseV2.java @@ -0,0 +1,120 @@ +package com.dnd.runus.presentation.v2.running.dto.response; + +import com.dnd.runus.application.running.dto.RunningResultDto; +import com.dnd.runus.domain.challenge.achievement.ChallengeAchievement; +import com.dnd.runus.domain.common.CoordinatePoint; +import com.dnd.runus.domain.common.Pace; +import com.dnd.runus.domain.goalAchievement.GoalAchievement; +import com.dnd.runus.domain.running.RunningRecord; +import com.dnd.runus.global.constant.RunningEmoji; +import com.dnd.runus.presentation.v1.running.dto.ChallengeDto; +import com.dnd.runus.presentation.v1.running.dto.GoalResultDto; +import com.dnd.runus.presentation.v2.running.dto.RouteDtoV2; +import com.dnd.runus.presentation.v2.running.dto.RouteDtoV2.Point; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.IntStream; + +public record RunningRecordResultResponseV2( + long runningRecordId, + @Schema(description = "러닝 시작 시간") + LocalDateTime startAt, + @Schema(description = "러닝 종료 시간") + LocalDateTime endAt, + @NotNull + @Schema(description = "감정 표현, very-good: 최고, good: 좋음, soso: 보통, bad: 나쁨, very-bad: 최악") + RunningEmoji emotion, + @NotNull + @Schema(description = "달성 모드, normal: 일반(목표 설정 X), challenge: 챌린지, goal: 목표") + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode achievementMode, + //todo 챌린지, 및 goal 관련해서 다시 구성 + @Schema(description = "챌린지 정보, achievementMode가 challenge인 경우에만 값이 존재합니다.") + ChallengeDto challenge, + @Schema(description = "목표 결과 정보, achievementMode가 goal인 경우에만 값이 존재합니다.") + GoalResultDto goal, + @NotNull + RunningRecordMetrics runningData +) { + @Schema(name = "RunningRecordMetrics for Result V2", description = "러닝 경로 정보") + public record RunningRecordMetrics( + @Schema(description = "평균 페이스", example = "5'30''") + Pace averagePace, + @Schema(description = "멈춘 시간을 제외한 실제로 달린 시간", example = "123:45:56", format = "HH:mm:ss") + Duration runningTime, + @Schema(description = "달린 거리(m)", example = "1000") + int distanceMeter, + @Schema(description = "소모 칼로리(kcal)", example = "100") + double calorie, + @Schema(description = "러닝 경로, 러닝 경로가 없는 경우(V2 이전 버전에 저장된 러닝 기록) null값을 리턴") + List route + ) { + } + + public static RunningRecordResultResponseV2 from(RunningResultDto runningRecordResult) { + RunningRecord runningRecord = runningRecordResult.runningRecord(); + return new RunningRecordResultResponseV2( + runningRecord.runningId(), + runningRecord.startAt().toLocalDateTime(), + runningRecord.endAt().toLocalDateTime(), + runningRecord.emoji(), + runningRecordResult.runningAchievementMode(), + runningRecordResult.runningAchievementMode() != com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.CHALLENGE ? null + : buildChallengeDto(runningRecordResult.challengeAchievement()), + runningRecordResult.runningAchievementMode() != com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.GOAL ? null + : buildGoalResultDto(runningRecordResult.goalAchievement()), + new RunningRecordMetrics( + runningRecord.averagePace(), + runningRecord.duration(), + runningRecord.distanceMeter(), + runningRecord.calorie(), + convertRouteDtoListFrom(runningRecord.route()) + ) + ); + } + + private static ChallengeDto buildChallengeDto(ChallengeAchievement achievement) { + if (achievement == null) { + return null; + } + return new ChallengeDto( + achievement.challenge().challengeId(), + achievement.challenge().name(), + achievement.description(), + achievement.challenge().imageUrl(), + achievement.isSuccess() + ); + } + + private static GoalResultDto buildGoalResultDto(GoalAchievement achievement) { + if (achievement == null) { + return null; + } + return new GoalResultDto( + achievement.getTitle(), + achievement.getDescription(), + achievement.getIconUrl(), + achievement.isAchieved() + ); + } + + + private static List convertRouteDtoListFrom( + List runningRecordRoute) { + // route가 null, empty, 또는 경로데이터를 사용하지 않았을 버전의 데이터 값 인경우 null를 리턴 + if (runningRecordRoute == null || runningRecordRoute.isEmpty()) { + return null; + } + if (runningRecordRoute.size() <= 2 && runningRecordRoute.getFirst().isNullIsland()){ + return null; + } + return IntStream.range(0, runningRecordRoute.size() / 2) + .mapToObj(i -> new RouteDtoV2( + Point.from(runningRecordRoute.get(i * 2)), + Point.from(runningRecordRoute.get(i * 2 + 1)) + )) + .toList(); + } +} diff --git a/src/test/java/com/dnd/runus/application/running/RunningRecordServiceTest.java b/src/test/java/com/dnd/runus/application/running/RunningRecordServiceTest.java index b97b1f66..1e11e21b 100644 --- a/src/test/java/com/dnd/runus/application/running/RunningRecordServiceTest.java +++ b/src/test/java/com/dnd/runus/application/running/RunningRecordServiceTest.java @@ -1,5 +1,6 @@ package com.dnd.runus.application.running; +import com.dnd.runus.application.running.dto.RunningResultDto; import com.dnd.runus.domain.challenge.achievement.ChallengeAchievement; import com.dnd.runus.domain.challenge.achievement.ChallengeAchievementPercentageRepository; import com.dnd.runus.domain.challenge.achievement.ChallengeAchievementRepository; @@ -25,8 +26,11 @@ import com.dnd.runus.presentation.v1.running.dto.response.RunningRecordMonthlySummaryResponse; import com.dnd.runus.presentation.v1.running.dto.response.RunningRecordQueryResponse; import com.dnd.runus.presentation.v1.running.dto.response.RunningRecordWeeklySummaryResponse; +import com.dnd.runus.presentation.v2.running.dto.RouteDtoV2; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2; 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.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; @@ -37,6 +41,8 @@ import java.time.*; import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static com.dnd.runus.global.constant.TimeConstant.SERVER_TIMEZONE; import static java.time.temporal.ChronoField.DAY_OF_WEEK; @@ -520,4 +526,77 @@ private RunningRecord createRunningRecord(RunningRecordRequestV1 request, Member .route(List.of(new CoordinatePoint(0, 0, 0), new CoordinatePoint(0, 0, 0))) .build(); } + + @Nested + @DisplayName("러닝 결과 저장 V2") + class RunningRecordAddV2 { + + @Test + @DisplayName("러닝 결과 저장 : 루트가 순서대로 들어갔는지 확인") + void addRunningRecordV2_CheckRoute() { + // given + RunningRecordRequestV2 request = new RunningRecordRequestV2( + LocalDateTime.of(2021, 1, 1, 12, 10, 30), + LocalDateTime.of(2021, 1, 1, 13, 12, 10), + "start location", + "end location", + RunningEmoji.VERY_GOOD, + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.NORMAL, + null, + null, + new RunningRecordRequestV2.RunningRecordMetrics( + Duration.ofSeconds(10_100), + 10_000, + 500.0, + List.of( + new RouteDtoV2(new RouteDtoV2.Point(0, 0), new RouteDtoV2.Point(1, 1)), + new RouteDtoV2(new RouteDtoV2.Point(2, 2), new RouteDtoV2.Point(3, 3)), + new RouteDtoV2(new RouteDtoV2.Point(4, 4), new RouteDtoV2.Point(5, 5))))); + + List route = request.runningData().route().stream() + .flatMap(point -> Stream.of( + new CoordinatePoint( + point.start().longitude(), point.start().latitude()), + new CoordinatePoint( + point.end().longitude(), point.end().latitude()))) + .collect(Collectors.toList()); + + Member member = new Member(MemberRole.USER, "nickname1"); + + RunningRecord expectedRecord = RunningRecord.builder() + .member(member) + .startAt(request.startAt().atZone(defaultZoneOffset)) + .endAt(request.endAt().atZone(defaultZoneOffset)) + .emoji(request.emotion()) + .startLocation(request.startLocation()) + .endLocation(request.endLocation()) + .distanceMeter(request.runningData().distanceMeter()) + .duration(request.runningData().runningTime()) + .calorie(request.runningData().calorie()) + .averagePace(Pace.from( + request.runningData().distanceMeter(), + request.runningData().runningTime())) + .route(route) + .build(); + + given(memberRepository.findById(1L)).willReturn(Optional.of(member)); + given(runningRecordRepository.save(expectedRecord)).willReturn(expectedRecord); + + // when + RunningResultDto response = runningRecordService.addRunningRecordV2(1L, request); + + // then + assertEquals( + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.NORMAL, + response.runningAchievementMode()); + assertNull(response.challengeAchievement()); + assertNull(response.goalAchievement()); + + RunningRecord resultRunning = response.runningRecord(); + for (int i = 0; i < resultRunning.route().size(); i++) { + assertEquals(i, resultRunning.route().get(i).longitude()); + assertEquals(i, resultRunning.route().get(i).latitude()); + } + } + } } diff --git a/src/test/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2Test.java b/src/test/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2Test.java new file mode 100644 index 00000000..7dc00cd8 --- /dev/null +++ b/src/test/java/com/dnd/runus/presentation/v2/running/RunningRecordControllerV2Test.java @@ -0,0 +1,161 @@ +package com.dnd.runus.presentation.v2.running; + +import com.dnd.runus.application.running.RunningRecordService; +import com.dnd.runus.application.running.RunningRecordServiceV2; +import com.dnd.runus.application.running.dto.RunningResultDto; +import com.dnd.runus.domain.common.CoordinatePoint; +import com.dnd.runus.domain.common.Pace; +import com.dnd.runus.domain.member.Member; +import com.dnd.runus.domain.running.RunningRecord; +import com.dnd.runus.global.constant.MemberRole; +import com.dnd.runus.presentation.config.ControllerTestHelper; +import com.dnd.runus.presentation.v2.running.dto.request.RunningRecordRequestV2; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WithMockUser +@WebMvcTest(RunningRecordControllerV2.class) +class RunningRecordControllerV2Test extends ControllerTestHelper { + + @Autowired + private RunningRecordControllerV2 runningRecordControllerV2; + + @MockBean + private RunningRecordService runningRecordService; + + @MockBean + private RunningRecordServiceV2 runningRecordServicev2; + + @Autowired + private ObjectMapper mapper; + + private long memberId; + private final ZoneOffset defaultZoneOffset = ZoneOffset.of("+9"); + + @BeforeEach + void setUp() { + setUpMockMvc(runningRecordControllerV2); + memberId = 1; + } + + @Test + @DisplayName("러닝 결과 추가에 대한 응답형식 확인") + void addRunningRecord_Normal_CheckRunningPath() throws Exception { + // given + String requestJson = + """ + { + "startAt" : "2024-12-23 17:49:36", + "endAt" : "2024-12-23 20:49:36", + "startLocation" : "서울시 강남구", + "endLocation" : "서울시 송파구", + "emotion" : "very-good", + "achievementMode" : "normal", + "runningData" : { + "runningTime" : "00:05:30", + "distanceMeter" : 1000, + "calorie" : 100.0, + "route" : [ { + "start" : { + "longitude" : 1.0, + "latitude" : 1.0 + }, + "end" : { + "longitude" : 2.0, + "latitude" : 2.0 + } + }, { + "start" : { + "longitude" : 3.0, + "latitude" : 3.0 + }, + "end" : { + "longitude" : 4.0, + "latitude" : 4.0 + } + }, { + "start" : { + "longitude" : 5.0, + "latitude" : 5.0 + }, + "end" : { + "longitude" : 6.0, + "latitude" : 6.0 + } + } ] + } + } + """; + RunningRecordRequestV2 request = mapper.readValue(requestJson, RunningRecordRequestV2.class); + given(runningRecordService.addRunningRecordV2(memberId, request)) + .willReturn(new RunningResultDto( + createRunningRecordFrom(request), + com.dnd.runus.presentation.v1.running.dto.request.RunningAchievementMode.NORMAL, + null, + null)); + + // when + ResultActions result = mvc.perform(post("/api/v2/running-records") + .param("memberId", String.valueOf(memberId)) + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)); + + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.emotion").value("very-good")) + .andExpect(jsonPath("$.data.achievementMode").value("normal")) + .andExpect(jsonPath("$.data.challenge").doesNotExist()) + .andExpect(jsonPath("$.data.goal").doesNotExist()) + .andExpect(jsonPath("$.data.runningData.averagePace").value("5’30”")) + .andExpect( + jsonPath("$.data.runningData.route[0].start.longitude").value("1.0")) + .andExpect(jsonPath("$.data.runningData.route[0].end.longitude").value("2.0")) + .andExpect( + jsonPath("$.data.runningData.route[1].start.longitude").value("3.0")) + .andExpect(jsonPath("$.data.runningData.route[1].end.longitude").value("4.0")) + .andExpect( + jsonPath("$.data.runningData.route[2].start.longitude").value("5.0")) + .andExpect(jsonPath("$.data.runningData.route[2].end.longitude").value("6.0")); + } + + private RunningRecord createRunningRecordFrom(RunningRecordRequestV2 request) { + return RunningRecord.builder() + .member(new Member(memberId, MemberRole.USER, "nickname1", OffsetDateTime.now(), OffsetDateTime.now())) + .startAt(request.startAt().atZone(defaultZoneOffset)) + .endAt(request.endAt().atZone(defaultZoneOffset)) + .emoji(request.emotion()) + .startLocation(request.startLocation()) + .endLocation(request.endLocation()) + .distanceMeter(request.runningData().distanceMeter()) + .duration(request.runningData().runningTime()) + .calorie(request.runningData().calorie()) + .averagePace(Pace.from( + request.runningData().distanceMeter(), + request.runningData().runningTime())) + .route(request.runningData().route().stream() + .flatMap(point -> Stream.of( + new CoordinatePoint( + point.start().longitude(), point.start().latitude()), + new CoordinatePoint( + point.end().longitude(), point.end().latitude()))) + .collect(Collectors.toList())) + .build(); + } +}