diff --git a/src/main/java/org/runimo/runimo/common/GlobalConsts.java b/src/main/java/org/runimo/runimo/common/GlobalConsts.java index 9ba476a4..ae828406 100644 --- a/src/main/java/org/runimo/runimo/common/GlobalConsts.java +++ b/src/main/java/org/runimo/runimo/common/GlobalConsts.java @@ -16,4 +16,6 @@ public final class GlobalConsts { "/v3/api-docs" ); + public static final String EMPTYFIELD = "EMPTY"; + } diff --git a/src/main/java/org/runimo/runimo/item/domain/Egg.java b/src/main/java/org/runimo/runimo/item/domain/Egg.java index 372def4f..6f0125cf 100644 --- a/src/main/java/org/runimo/runimo/item/domain/Egg.java +++ b/src/main/java/org/runimo/runimo/item/domain/Egg.java @@ -9,10 +9,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import static org.runimo.runimo.common.GlobalConsts.EMPTYFIELD; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Egg extends Item { + public static final Egg EMPTY = new Egg(EMPTYFIELD, EMPTYFIELD, EMPTYFIELD, EMPTYFIELD, null, 0L); @Column(name = "egg_type") @Enumerated(EnumType.STRING) private EggType eggType; 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 20b5fbdb..7c593f64 100644 --- a/src/main/java/org/runimo/runimo/records/controller/RecordController.java +++ b/src/main/java/org/runimo/runimo/records/controller/RecordController.java @@ -7,8 +7,10 @@ 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.records.controller.requests.RecordSaveRequest; import org.runimo.runimo.records.controller.requests.RecordUpdateRequest; +import org.runimo.runimo.records.enums.RecordHttpResponse; import org.runimo.runimo.records.service.usecases.RecordCreateUsecase; import org.runimo.runimo.records.service.usecases.RecordQueryUsecase; import org.runimo.runimo.records.service.usecases.RecordUpdateUsecase; @@ -39,11 +41,13 @@ public class RecordController { @ApiResponse(responseCode = "401", description = "인증 실패") }) @PostMapping - public ResponseEntity saveRecord( - @RequestBody RecordSaveRequest request + public ResponseEntity> saveRecord( + @RequestBody RecordSaveRequest request, + @UserId Long userId ) { - RecordSaveResponse response = recordCreateUsecase.execute(RecordCreateCommand.from(request)); - return ResponseEntity.created(URI.create("/api/v1/records/" + response.savedId())).body(response); + RecordSaveResponse response = recordCreateUsecase.execute(RecordCreateCommand.from(request, userId)); + return ResponseEntity.created(URI.create("/api/v1/records/" + response.savedId())) + .body(SuccessResponse.of(RecordHttpResponse.RECORD_SAVED, response)); } @Operation(summary = "기록 조회", description = "기록을 조회합니다.") diff --git a/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java b/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java index f9ea60bd..62a0ad58 100644 --- a/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java +++ b/src/main/java/org/runimo/runimo/records/controller/requests/RecordSaveRequest.java @@ -6,8 +6,6 @@ @Schema(description = "사용자 달리기 기록 저장 요청 DTO") public record RecordSaveRequest( - @Schema(description = "사용자 고유 식별자", example = "c7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b") - String userPublicId, @Schema(description = "달리기 제목", example = "오늘의 달리기") LocalDateTime startedAt, @Schema(description = "달리기 시작 시각", example = "2021-10-10T10:10:10") 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 e0c13e5a..3bcae9a7 100644 --- a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java +++ b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java @@ -29,14 +29,17 @@ public class RunningRecord extends BaseEntity { private Distance totalDistance; @Embedded private Pace averagePace; + @Column(name = "is_rewarded", nullable = false) + private Boolean isRewarded; @Builder - public RunningRecord(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace) { + public RunningRecord(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace, Boolean isRewarded) { this.userId = userId; this.title = title; this.recordPublicId = UUID.randomUUID().toString(); this.startedAt = startedAt; this.endAt = endAt; + this.isRewarded = isRewarded; this.totalDistance = totalDistance; this.averagePace = averagePace; } @@ -59,8 +62,17 @@ public void update(RunningRecord updatedEntity) { this.endAt = updatedEntity.getEndAt(); this.averagePace = updatedEntity.averagePace; this.recordPublicId = updatedEntity.recordPublicId; + this.isRewarded = updatedEntity.isRewarded; } + public void reward(Long editorId) { + validateEditor(editorId); + this.isRewarded = true; + } + + public boolean isRecordAlreadyRewarded() { + return this.isRewarded; + } private void validateEditor(Long editorId) { if (editorId == null || !Objects.equals(this.userId, editorId)) { diff --git a/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java new file mode 100644 index 00000000..3c6aa550 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/enums/RecordHttpResponse.java @@ -0,0 +1,40 @@ +package org.runimo.runimo.records.enums; +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.springframework.http.HttpStatus; + +public enum RecordHttpResponse implements CustomResponseCode { + RECORD_SAVED("RSH2001", "달리기 기록 저장 성공", "달리기 기록 저장 성공"), + ; + + private final String code; + private final String clientMessage; + private final String logMessage; + + RecordHttpResponse(String code, String clientMessage, String logMessage) { + this.code = code; + this.clientMessage = clientMessage; + this.logMessage = logMessage; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getClientMessage() { + return this.clientMessage; + } + + @Override + public String getLogMessage() { + return this.logMessage; + } + + @Override + public HttpStatus getHttpStatusCode() { + return null; + } +} + + 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 e47d67fb..58274226 100644 --- a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -1,12 +1,27 @@ package org.runimo.runimo.records.repository; import org.runimo.runimo.records.domain.RunningRecord; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Optional; @Repository public interface RecordRepository extends JpaRepository { Optional findByRecordPublicId(String id); + + @Query("SELECT r FROM RunningRecord r " + + "WHERE r.userId = :userId " + + "AND r.startedAt BETWEEN :startOfWeek AND :now") + Slice findFirstRunOfWeek( + @Param("userId") Long userId, + @Param("startOfWeek") LocalDateTime startOfWeek, + @Param("now") LocalDateTime now, + Pageable pageable + ); } diff --git a/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java index 6f3a81bb..0e39813a 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java @@ -21,7 +21,7 @@ public class RecordCommandService { @Transactional public RecordSaveResponse saveRecord(Long userId, RecordCreateCommand command) { - RunningRecord runningRecord = mapToRunningRecord(userId, command); + RunningRecord runningRecord = mapToCreatedRunningRecord(userId, command); recordRepository.save(runningRecord); return new RecordSaveResponse(runningRecord.getId()); } @@ -45,13 +45,14 @@ private RunningRecord mapToUpdateRecord(RecordUpdateCommand command) { ); } - private RunningRecord mapToRunningRecord(Long id, RecordCreateCommand command) { + private RunningRecord mapToCreatedRunningRecord(Long id, RecordCreateCommand command) { return RunningRecord.builder() .userId(id) .startedAt(command.startedAt()) .endAt(command.endAt()) .averagePace(command.averagePace()) .totalDistance(command.totalDistance()) + .isRewarded(false) .build(); } } 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 a6ff216d..d0427814 100644 --- a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -3,9 +3,12 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.repository.RecordRepository; +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.Optional; @Component @@ -23,4 +26,12 @@ public Optional findById(Long id) { public Optional findByPublicId(String id) { return recordRepository.findByRecordPublicId(id); } + + @Transactional(readOnly = true) + public Optional findFirstRunOfCurrentWeek(Long userId) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startOfWeek = now.minusDays(now.getDayOfWeek().getValue() - 1L).withHour(0).withMinute(0).withSecond(0); + PageRequest pageRequest = PageRequest.of(0, 1, Sort.by("startedAt").ascending()); + return recordRepository.findFirstRunOfWeek(userId, startOfWeek, now, pageRequest).stream().findFirst(); + } } diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java index 7b16f01b..3e978b07 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java @@ -23,7 +23,7 @@ public class RecordCreateUsecaseImpl implements RecordCreateUsecase { @Override @Transactional public RecordSaveResponse execute(RecordCreateCommand command) { - User user = userFinder.findUserByPublicId(command.userPublicId()) + User user = userFinder.findUserById(command.userId()) .orElseThrow(NoSuchElementException::new); userStatService.updateUserStats(user, command); return commandService.saveRecord(user.getId(), command); diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java index c359ccd1..5bff2c0b 100644 --- a/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java +++ b/src/main/java/org/runimo/runimo/records/service/usecases/dtos/RecordCreateCommand.java @@ -8,16 +8,16 @@ import java.time.temporal.ChronoUnit; public record RecordCreateCommand( - String userPublicId, + Long userId, LocalDateTime startedAt, LocalDateTime endAt, Pace averagePace, Distance totalDistance ) { - public static RecordCreateCommand from(RecordSaveRequest request) { + public static RecordCreateCommand from(final RecordSaveRequest request, final Long userId) { return new RecordCreateCommand( - request.userPublicId(), + userId, request.startedAt(), request.endAt(), new Pace(request.averagePaceInMilliSeconds()), diff --git a/src/main/java/org/runimo/runimo/rewards/README.md b/src/main/java/org/runimo/runimo/rewards/README.md new file mode 100644 index 00000000..302edc3e --- /dev/null +++ b/src/main/java/org/runimo/runimo/rewards/README.md @@ -0,0 +1,34 @@ +# 보상 지급관련 패키지 + +## 요구사항 + +### 1. 주간 첫번째 달리기를 마치면 알을 지급한다. + +- 사용자는 주마다 첫번째 달리기를 마치면 `알`을 보상으로 받는다. + - ex) 1주일동안 3번 달렸다면, 1번째 달린 날에 `알`을 받는다. + + +- `알`은 사용자의 누적 달리기 거리에 따라 출현 확률이 결정된다. + - ex) 사용자가 30km를 누적으로 달렸다면 2가지 알이 해금되었고, 1/2의 확률로 각각의 알이 출현한다. + +#### 제한 사항: +- 주간은 월요일 ~ 일요일 단위로 계산한다. +- 주간 첫번째 달리기는 월요일 00:00:00 ~ 일요일 23:59:59 사이에 달려야 한다. +- 사용자는 주간 첫번째 달리기를 마치면 `알`을 받는다. +- 이미 달린 경우 알을 받을 수 없다. +- **알 지급에 대한 기록을 남겨야한다.** + + +### 2. 매 달리기마다 애정을 지급한다. + +- 사용자는 매 달리기마다 `애정`을 보상으로 받는다. + + +- `애정`은 1km에 1개씩 지급된다. + + +- 달린거리를 `내림` 하여 `애정`을 지급한다. + - ex) 3.5km를 달렸다면 3개의 `애정`을 받는다. + - ex) 3.1km를 달렸다면 3개의 `애정`을 받는다. + - ex) 2.9km를 달렸다면 2개의 `애정`을 받는다. + diff --git a/src/main/java/org/runimo/runimo/rewards/service/RewardService.java b/src/main/java/org/runimo/runimo/rewards/service/RewardService.java index 71bb0b61..a4e4f84d 100644 --- a/src/main/java/org/runimo/runimo/rewards/service/RewardService.java +++ b/src/main/java/org/runimo/runimo/rewards/service/RewardService.java @@ -1,7 +1,9 @@ package org.runimo.runimo.rewards.service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.runimo.runimo.item.domain.Egg; +import org.runimo.runimo.records.domain.RunningRecord; import org.runimo.runimo.records.service.RecordFinder; import org.runimo.runimo.rewards.service.eggs.EggGrantService; import org.runimo.runimo.rewards.service.dtos.RewardClaimCommand; @@ -9,9 +11,12 @@ import org.runimo.runimo.user.domain.User; import org.runimo.runimo.user.service.UserFinder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.NoSuchElementException; +import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor public class RewardService { @@ -20,14 +25,40 @@ public class RewardService { private final UserFinder userFinder; private final EggGrantService eggGrantService; + @Transactional public RewardResponse claimReward(RewardClaimCommand command) { - User user = userFinder.findUserById(command.userId()) + RunningRecord runningRecord = recordFinder.findById(command.recordId()) .orElseThrow(NoSuchElementException::new); - recordFinder.findById(command.recordId()) - .orElseThrow(NoSuchElementException::new); - // grant egg - Egg grantedEgg = eggGrantService.grantRandomEggToUser(user); + validateRecord(runningRecord); + Egg grantedEgg = rewardEgg(command); + runningRecord.reward(command.userId()); //TODO: 애정 보상 return new RewardResponse(grantedEgg.getItemCode(), grantedEgg.getEggType()); } + + private Egg rewardEgg(RewardClaimCommand command) { + User user = userFinder.findUserById(command.userId()) + .orElseThrow(NoSuchElementException::new); + if(validateRecordIsFirstRecordOfWeek(command)) { + return eggGrantService.grantRandomEggToUser(user); + } + return Egg.EMPTY; + } + + private void validateRecord(RunningRecord runningRecord) { + if(runningRecord.isRecordAlreadyRewarded()) throw new IllegalStateException("이미 보상이 지급되었습니다."); + } + + private boolean validateRecordIsFirstRecordOfWeek(RewardClaimCommand command) { + Optional firstRecordOfWeek = recordFinder.findFirstRunOfCurrentWeek(command.userId()); + if(firstRecordOfWeek.isEmpty()) { + log.info("유저 {}의 첫번째 달리기 기록이 없습니다.", command.userId()); + return false; + } + if(!command.recordId().equals(firstRecordOfWeek.get().getId())){ + log.info("유저 {}의 첫번째 달리기 기록이 아닙니다.", command.userId()); + return false; + } + return true; + } } diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 1fa8b843..2206bc52 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -60,6 +60,7 @@ CREATE TABLE `running_records` `end_at` timestamp, `total_distance` integer, `pace_in_milli_seconds` integer, + `is_rewarded` boolean, `created_at` timestamp, `updated_at` timestamp, `deleted_at` TIMESTAMP diff --git a/src/test/java/org/runimo/runimo/CleanUpUtil.java b/src/test/java/org/runimo/runimo/CleanUpUtil.java index 460aa398..ada387e7 100644 --- a/src/test/java/org/runimo/runimo/CleanUpUtil.java +++ b/src/test/java/org/runimo/runimo/CleanUpUtil.java @@ -10,7 +10,8 @@ public class CleanUpUtil { private static final String[] USER_TABLES = { "user_item", "oauth_accounts", - "users" + "users", + "running_records", }; @Autowired diff --git a/src/test/java/org/runimo/runimo/rewards/RewardTest.java b/src/test/java/org/runimo/runimo/rewards/RewardTest.java index 06a98286..7449a8bc 100644 --- a/src/test/java/org/runimo/runimo/rewards/RewardTest.java +++ b/src/test/java/org/runimo/runimo/rewards/RewardTest.java @@ -60,7 +60,7 @@ void tearDown() { @Test void 보상_요청_테스트() { RecordCreateCommand recordCreateCommand = new RecordCreateCommand( - savedUser.getPublicId(), + savedUser.getId(), LocalDateTime.now(), LocalDateTime.now().plusHours(1), new Pace(1909L), diff --git a/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java new file mode 100644 index 00000000..6528d4fc --- /dev/null +++ b/src/test/java/org/runimo/runimo/rewards/api/RewardAcceptanceTest.java @@ -0,0 +1,198 @@ +package org.runimo.runimo.rewards.api; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +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.runimo.runimo.records.controller.requests.RecordSaveRequest; +import org.runimo.runimo.rewards.controller.requests.RewardClaimRequest; +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 java.time.LocalDateTime; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RewardAcceptanceTest { + + @LocalServerPort + private int port; + @Autowired + private JwtTokenFactory jwtTokenFactory; + + @Autowired + private CleanUpUtil cleanUpUtil; + @Autowired + private ObjectMapper objectMapper; + + private static final LocalDateTime pivotTime = LocalDateTime.of(2023, 10, 1, 10, 0); + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @AfterEach() + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 달리기_기록_저장_후_주간_첫번째_달리기보상_수령() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + RecordSaveRequest request = new RecordSaveRequest( + pivotTime, + pivotTime.plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse res = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(request)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer recordId = res.extract().path("payload.saved_id"); + + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(recordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .body("payload", notNullValue()); + } + + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 달리기_보상_수령_후_재시도_시_예외() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + RecordSaveRequest request = new RecordSaveRequest( + pivotTime, + pivotTime.plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse res = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(request)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer recordId = res.extract().path("payload.saved_id"); + + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(recordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("payload", notNullValue()); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.BAD_REQUEST.value()); + } + + @Test + @Sql(scripts = "/sql/user_item_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 첫번째_기록이_이번주_첫번째_달리기가_아니라서_알_미지급() throws JsonProcessingException { + String header = "Bearer " + jwtTokenFactory.generateAccessToken("test-user-uuid-1"); + + // 첫번째 기록 저장 + RecordSaveRequest firstRequest = new RecordSaveRequest( + pivotTime, + pivotTime.plusMinutes(20), + 1000L, + 1000L); + ValidatableResponse firstRes = given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(firstRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + Integer firstRecordId = firstRes.extract().path("payload.saved_id"); + + // 두번째 기록 저장 (startedAt이 더 빠르게) + RecordSaveRequest secondRequest = new RecordSaveRequest( + pivotTime.minusDays(1), + pivotTime.minusDays(1).plusMinutes(20), + 1000L, + 1000L); + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(secondRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/records") + .then() + .log().ifValidationFails() + .statusCode(HttpStatus.CREATED.value()) + .body("payload", notNullValue()) + .body("payload.saved_id", notNullValue()); + + // 첫번째 기록의 id로 보상 요청 + RewardClaimRequest rewardClaimRequest = new RewardClaimRequest(Long.valueOf(firstRecordId)); + + given() + .header("Authorization", header) + .body(objectMapper.writeValueAsString(rewardClaimRequest)) + .contentType(ContentType.JSON) + .when() + .post("/api/v1/rewards/runnings") + .then() + .log().all() + .statusCode(HttpStatus.OK.value()) + .body("payload.egg_type", nullValue()) + .body("payload.egg_code", equalTo("EMPTY")); + } + + + +} diff --git a/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java b/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java new file mode 100644 index 00000000..a28f2770 --- /dev/null +++ b/src/test/java/org/runimo/runimo/rewards/service/RewardServiceTest.java @@ -0,0 +1,94 @@ +package org.runimo.runimo.rewards.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.item.domain.Egg; +import org.runimo.runimo.item.domain.EggType; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.rewards.service.dtos.RewardClaimCommand; +import org.runimo.runimo.rewards.service.dtos.RewardResponse; +import org.runimo.runimo.rewards.service.eggs.EggGrantService; +import org.runimo.runimo.user.UserFixtures; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.mockito.MockitoAnnotations.openMocks; + +class RewardServiceTest { + + private RewardService rewardService; + @Mock + private RecordFinder recordFinder; + @Mock + private UserFinder userFinder; + @Mock + private EggGrantService eggGrantService; + + private static RunningRecord getRunningRecordWithIds(Long userId, Long recordId, boolean isRewarded) { + RunningRecord runningRecord = RunningRecord.builder() + .userId(userId) + .isRewarded(isRewarded) + .totalDistance(new Distance(1000L)) + .startedAt(LocalDateTime.now()) + .build(); + ReflectionTestUtils.setField(runningRecord, "id", recordId); + return runningRecord; + } + + @BeforeEach + void setUp() { + openMocks(this); + rewardService = new RewardService(recordFinder, userFinder, eggGrantService); + when(userFinder.findUserById(any())).thenReturn(Optional.of(UserFixtures.getUserWithId(1L))); + } + + @Test + void 보상을_받지_않은_이번주_첫_기록이라면_알을_지급한다() { + RunningRecord unRewardedRecord = getRunningRecordWithIds(1L, 1L, false); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(eggGrantService.grantRandomEggToUser(any())).thenReturn(Egg.builder().eggType(EggType.MADANG).build()); + + RewardResponse res = rewardService.claimReward(command); + assertNotNull(res); + assertEquals(EggType.MADANG, res.eggType()); + } + + @Test + void 이미_보상을_받은_기록이면_예외를_던진다() { + RunningRecord alreadyRewardedRecord = getRunningRecordWithIds(1L, 1L, true); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(alreadyRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(java.util.Optional.of(alreadyRewardedRecord)); + when(eggGrantService.grantRandomEggToUser(any())).thenReturn(Egg.builder().eggType(EggType.MADANG).build()); + + assertThrows(IllegalStateException.class, () -> rewardService.claimReward(command)); + } + + @Test + void 이번주_첫_기록이_아니면_알을_지급하지_않는다() { + RunningRecord unRewardedRecord = getRunningRecordWithIds(1L, 1L, false); + RunningRecord anotherRecord = getRunningRecordWithIds(1L, 2L, false); + RewardClaimCommand command = new RewardClaimCommand(1L, 1L); + + when(recordFinder.findById(any())).thenReturn(java.util.Optional.of(unRewardedRecord)); + when(recordFinder.findFirstRunOfCurrentWeek(any())).thenReturn(Optional.of(anotherRecord)); + + RewardResponse res = rewardService.claimReward(command); + verify(eggGrantService, never()).grantRandomEggToUser(any()); + assertEquals(Egg.EMPTY.getItemCode(), res.eggCode()); + assertNull(res.eggType()); + } +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/UserFixtures.java b/src/test/java/org/runimo/runimo/user/UserFixtures.java index 4800024a..d7e6469a 100644 --- a/src/test/java/org/runimo/runimo/user/UserFixtures.java +++ b/src/test/java/org/runimo/runimo/user/UserFixtures.java @@ -1,6 +1,7 @@ package org.runimo.runimo.user; import org.runimo.runimo.user.domain.User; +import org.springframework.test.util.ReflectionTestUtils; public final class UserFixtures { @@ -12,4 +13,15 @@ public static User getDefaultUser() { .totalTimeInSeconds(0L) .build(); } + + public static User getUserWithId(Long id) { + User user = User.builder() + .nickname("test") + .imgUrl("test") + .totalDistanceInMeters(0L) + .totalTimeInSeconds(0L) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } } diff --git a/src/test/resources/sql/schema.sql b/src/test/resources/sql/schema.sql index 1fa8b843..2206bc52 100644 --- a/src/test/resources/sql/schema.sql +++ b/src/test/resources/sql/schema.sql @@ -60,6 +60,7 @@ CREATE TABLE `running_records` `end_at` timestamp, `total_distance` integer, `pace_in_milli_seconds` integer, + `is_rewarded` boolean, `created_at` timestamp, `updated_at` timestamp, `deleted_at` TIMESTAMP