Skip to content

Commit

Permalink
feat: ✨ 최근 목표 금액 조회 API (#109)
Browse files Browse the repository at this point in the history
* docs: 최근 설정 목표 금액 api 스웨거 문서 작성

* feat: 최근 목표 금액 조회 dto 작성

* feat: 최근 목표 금액 조회 controller 정의

* test: jpa named rule 최근 목표 금액 조회 메서드 단위 테스트

* test: jpa 메서드명 규칙 -> query dsl 메서드로 수정

* feat: user_id 기반 최근 목표 금액 데이터 조회 메서드 추가

* test: jpa query factory 의존성 주입

* test: 최근 목표 금액 없는 경우 테스트

* feat: 최근 목표 금액 조회 서비스 구현

* feat: 최근 목표 금액 조회 dto 변환 mapper 메서드 추가

* feat: 최근 목표 금액 조회 use case 작성
  • Loading branch information
psychology50 authored Jun 6, 2024
1 parent 07c6057 commit 079a61a
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ public interface TargetAmountApi {
schemaProperties = @SchemaProperty(name = "targetAmounts", array = @ArraySchema(schema = @Schema(implementation = TargetAmountDto.WithTotalSpendingRes.class)))))
ResponseEntity<?> getTargetAmountsAndTotalSpendings(@Validated TargetAmountDto.DateParam param, @AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "당월 이전 사용자가 입력한 목표 금액 중 최신 데이터 단일 조회", method = "GET",
description = "당월에 목표 금액이 존재한다면 당월 목표 금액이 반환되겠지만, 일반적으로 해당 API는 당월 목표 금액 조회 시 isRead가 false인 경우이므로 amount도 -1이라는 전제를 두어 별도의 예외처리를 수행하지는 않는다. isPresent 필드를 통해 데이터 존재 여부를 확인할 수 있다.")
@ApiResponse(responseCode = "200", description = "목표 금액 조회 성공", content = @Content(schemaProperties = @SchemaProperty(name = "targetAmount", schema = @Schema(implementation = TargetAmountDto.RecentTargetAmountRes.class))))
ResponseEntity<?> getRecentTargetAmount(@AuthenticationPrincipal SecurityUserDetails user);

@Operation(summary = "당월 목표 금액 수정", method = "PATCH")
@Parameters({
@Parameter(name = "targetAmountId", description = "수정하려는 목표 금액 ID", required = true, example = "1", in = ParameterIn.PATH),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ public ResponseEntity<?> getTargetAmountsAndTotalSpendings(@Validated TargetAmou
return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNTS, targetAmountUseCase.getTargetAmountsAndTotalSpendings(user.getUserId(), param.date())));
}

@Override
@GetMapping("/recent")
@PreAuthorize("isAuthenticated()")
public ResponseEntity<?> getRecentTargetAmount(@AuthenticationPrincipal SecurityUserDetails user) {
return ResponseEntity.ok(SuccessResponse.from(TARGET_AMOUNT, targetAmountUseCase.getRecentTargetAmount(user.getUserId())));
}

@Override
@PatchMapping("/{target_amount_id}")
@PreAuthorize("isAuthenticated() and @targetAmountManager.hasPermission(principal.userId, #targetAmountId)")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package kr.co.pennyway.api.apis.ledger.dto;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import io.swagger.v3.oas.annotations.media.Schema;
Expand Down Expand Up @@ -88,4 +89,23 @@ public static TargetAmountInfo from(TargetAmount targetAmount) {
return new TargetAmountInfo(targetAmount.getId(), targetAmount.getAmount(), targetAmount.isRead());
}
}

@Schema(title = "가장 최근에 입력한 목표 금액 정보")
public record RecentTargetAmountRes(
@Schema(description = "최근 목표 금액 존재 여부로써 데이터가 존재하지 않으면 false, 존재하면 true", example = "true", requiredMode = Schema.RequiredMode.REQUIRED)
boolean isPresent,
@Schema(description = "최근 목표 금액 정보. isPresent가 false인 경우 필드가 존재하지 않는다.", requiredMode = Schema.RequiredMode.REQUIRED)
@JsonInclude(JsonInclude.Include.NON_NULL)
Integer amount
) {
public RecentTargetAmountRes {
if (!isPresent) {
amount = null;
}
}

public static RecentTargetAmountRes valueOf(Integer amount) {
return (amount.equals(-1)) ? new RecentTargetAmountRes(false, null) : new RecentTargetAmountRes(true, amount);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ public static List<TargetAmountDto.WithTotalSpendingRes> toWithTotalSpendingResp
.toList();
}

/**
* 최근 목표 금액을 응답 형태로 변환한다.
*
* @return TargetAmountDto.RecentTargetAmountRes
*/
public static TargetAmountDto.RecentTargetAmountRes toRecentTargetAmountResponse(Integer amount) {
return TargetAmountDto.RecentTargetAmountRes.valueOf(amount);
}

private static List<TargetAmountDto.WithTotalSpendingRes> createWithTotalSpendingResponses(Map<YearMonth, TargetAmount> targetAmounts, Map<YearMonth, Integer> totalSpendings, LocalDate startAt, int monthLength) {
List<TargetAmountDto.WithTotalSpendingRes> withTotalSpendingResponses = new ArrayList<>(monthLength + 1);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package kr.co.pennyway.api.apis.ledger.service;

import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
import kr.co.pennyway.domain.domains.target.service.TargetAmountService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class RecentTargetAmountSearchService {
private final TargetAmountService targetAmountService;

@Transactional(readOnly = true)
public Integer readRecentTargetAmount(Long userId) {
return targetAmountService.readRecentTargetAmount(userId)
.map(TargetAmount::getAmount)
.orElse(-1);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import kr.co.pennyway.api.apis.ledger.dto.TargetAmountDto;
import kr.co.pennyway.api.apis.ledger.mapper.TargetAmountMapper;
import kr.co.pennyway.api.apis.ledger.service.RecentTargetAmountSearchService;
import kr.co.pennyway.api.apis.ledger.service.TargetAmountSaveService;
import kr.co.pennyway.common.annotation.UseCase;
import kr.co.pennyway.domain.common.redisson.DistributedLockPrefix;
Expand Down Expand Up @@ -32,6 +33,7 @@ public class TargetAmountUseCase {
private final SpendingService spendingService;

private final TargetAmountSaveService targetAmountSaveService;
private final RecentTargetAmountSearchService recentTargetAmountSearchService;

@Transactional
public TargetAmountDto.TargetAmountInfo createTargetAmount(Long userId, int year, int month) {
Expand Down Expand Up @@ -60,6 +62,11 @@ public List<TargetAmountDto.WithTotalSpendingRes> getTargetAmountsAndTotalSpendi
return TargetAmountMapper.toWithTotalSpendingResponses(targetAmounts, totalSpendings, user.getCreatedAt().toLocalDate(), date);
}

@Transactional(readOnly = true)
public TargetAmountDto.RecentTargetAmountRes getRecentTargetAmount(Long userId) {
return TargetAmountMapper.toRecentTargetAmountResponse(recentTargetAmountSearchService.readRecentTargetAmount(userId));
}

@Transactional
public TargetAmountDto.TargetAmountInfo updateTargetAmount(Long targetAmountId, Integer amount) {
TargetAmount targetAmount = targetAmountService.readTargetAmount(targetAmountId)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package kr.co.pennyway.domain.domains.target.repository;

import kr.co.pennyway.domain.domains.target.domain.TargetAmount;

import java.time.LocalDate;
import java.util.Optional;

public interface TargetAmountCustomRepository {
Optional<TargetAmount> findRecentOneByUserId(Long userId);

boolean existsByUserIdThatMonth(Long userId, LocalDate date);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import com.querydsl.jpa.impl.JPAQueryFactory;
import kr.co.pennyway.domain.domains.target.domain.QTargetAmount;
import kr.co.pennyway.domain.domains.target.domain.TargetAmount;
import kr.co.pennyway.domain.domains.user.domain.QUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.Optional;

@Slf4j
@Repository
@RequiredArgsConstructor
public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository {
Expand All @@ -16,6 +20,23 @@ public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepos
private final QUser user = QUser.user;
private final QTargetAmount targetAmount = QTargetAmount.targetAmount;

/**
* 사용자의 가장 최근 목표 금액을 조회한다.
*
* @return 최근 목표 금액이 존재하지 않을 경우 Optional.empty()를 반환하며, 당월 목표 금액 정보일 수도 있다.
*/
@Override
public Optional<TargetAmount> findRecentOneByUserId(Long userId) {
TargetAmount result = queryFactory.selectFrom(targetAmount)
.innerJoin(user).on(targetAmount.user.id.eq(user.id))
.where(user.id.eq(userId)
.and(targetAmount.amount.gt(-1)))
.orderBy(targetAmount.createdAt.desc())
.fetchFirst();

return Optional.ofNullable(result);
}

@Override
public boolean existsByUserIdThatMonth(Long userId, LocalDate date) {
return queryFactory.selectOne().from(targetAmount)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public List<TargetAmount> readTargetAmountsByUserId(Long userId) {
return targetAmountRepository.findByUser_Id(userId);
}

@Transactional(readOnly = true)
public Optional<TargetAmount> readRecentTargetAmount(Long userId) {
return targetAmountRepository.findRecentOneByUserId(userId);
}

@Transactional(readOnly = true)
public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) {
return targetAmountRepository.existsByUserIdThatMonth(userId, date);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package kr.co.pennyway.domain.domains.target.repository;

import kr.co.pennyway.domain.config.ContainerMySqlTestConfig;
import kr.co.pennyway.domain.config.JpaConfig;
import kr.co.pennyway.domain.config.TestJpaConfig;
import kr.co.pennyway.domain.domains.user.domain.User;
import kr.co.pennyway.domain.domains.user.repository.UserRepository;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Assertions;
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.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"})
@ContextConfiguration(classes = JpaConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Import(TestJpaConfig.class)
public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig {
private final Collection<MockTargetAmount> mockTargetAmounts = List.of(
MockTargetAmount.of(10000, true, LocalDateTime.of(2021, 1, 1, 0, 0, 0), LocalDateTime.of(2021, 1, 1, 0, 0, 0)),
MockTargetAmount.of(-1, false, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)),
MockTargetAmount.of(20000, true, LocalDateTime.of(2022, 5, 1, 0, 0, 0), LocalDateTime.of(2022, 5, 1, 0, 0, 0)),
MockTargetAmount.of(30000, true, LocalDateTime.of(2023, 7, 1, 0, 0, 0), LocalDateTime.of(2023, 7, 1, 0, 0, 0)),
MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 2, 1, 0, 0, 0), LocalDateTime.of(2024, 2, 1, 0, 0, 0))
);
private final Collection<MockTargetAmount> mockTargetAmountsMinus = List.of(
MockTargetAmount.of(-1, true, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)),
MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)),
MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0))
);
@Autowired
private UserRepository userRepository;
@Autowired
private TargetAmountRepository targetAmountRepository;
;
@Autowired
private NamedParameterJdbcTemplate jdbcTemplate;

@Test
@DisplayName("사용자의 가장 최근 목표 금액을 조회할 수 있다.")
@Transactional
public void 가장_최근_사용자_목표_금액_조회() {
// given
User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build());
bulkInsertTargetAmount(user, mockTargetAmounts);

// when - then
targetAmountRepository.findRecentOneByUserId(user.getId())
.ifPresentOrElse(
targetAmount -> assertEquals(targetAmount.getAmount(), 30000),
() -> Assertions.fail("최근 목표 금액이 존재하지 않습니다.")
);
}

@Test
@DisplayName("사용자의 가장 최근 목표 금액이 존재하지 않으면 Optional.empty()를 반환한다.")
@Transactional
public void 가장_최근_사용자_목표_금액_미존재() {
// given
User user = userRepository.save(User.builder().username("jayang").name("Yang").phone("010-0000-0000").build());
bulkInsertTargetAmount(user, mockTargetAmountsMinus);

// when - then
targetAmountRepository.findRecentOneByUserId(user.getId())
.ifPresentOrElse(
targetAmount -> Assertions.fail("최근 목표 금액이 존재합니다."),
() -> log.info("최근 목표 금액이 존재하지 않습니다.")
);
}

private void bulkInsertTargetAmount(User user, Collection<MockTargetAmount> targetAmounts) {
String sql = String.format("""
INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at)
VALUES (:amount, true, :userId, :createdAt, :updatedAt)
""", "target_amount");
SqlParameterSource[] params = targetAmounts.stream()
.map(mockTargetAmount -> new MapSqlParameterSource()
.addValue("amount", mockTargetAmount.amount)
.addValue("userId", user.getId())
.addValue("createdAt", mockTargetAmount.createdAt)
.addValue("updatedAt", mockTargetAmount.updatedAt))
.toArray(SqlParameterSource[]::new);
jdbcTemplate.batchUpdate(sql, params);
}

private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) {
public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) {
return new MockTargetAmount(amount, isRead, createdAt, updatedAt);
}
}
}

0 comments on commit 079a61a

Please sign in to comment.