8.1 프로젝트 생성 및 요구사항 분석
8.1-1 계좌
- 계좌 시스템은 사용자와 계좌 정보를 저장하며 외부 시스템에서 거래를 요청할 경우, 결제 , 결제 취소 , 거래 관리 기능을 제공하는 시스템이다.
- 사용자는 신규 등로그 해지, 중지, 사용자 정보 조회 등 기능을 제공
- 계좌는 신규, 해지, 확인 기능을 제공하낟. 인당 최대 10개 계좌를 만들 수 있다. 계좌 번호는 10자리 정수로 이뤄진다.
계좌 생성 API
- POST/ account
- 파라미터: 사용자 ID, 초기 잔액
- 정책: 사용자가 없는 경우, 계좌가 이미 10개인 경우 실패 응답
- 성공 응답: 사용자 ID, 계좌 번호, 등록 일시
계좌 해지 API
- DELETER/ account
- 파라미터: 사용자 ID, 비밀번호
- 정책: 사용자 계좌가 없는 경우, ID와 계좌 소유주가 다른 경우, 계좌가 이미 해지된 경우, 잔액 있는 경우 실패 응답
- 성공 응답: 사용자 ID, 계좌번호, 해지 일시
계좌 확인 API
- get/ account?user_id={userId}
- 파라미터: 사용자ID
- 정책: 시용자가 없는 경우 실패 응답
- 성공 응답: List<계좌 번호, 잔액> 구조로 응답
8.1-2 거래 정보
잔액 사용 API
- POST/ transaction/ use
- 파라미터: 사용자ID, 계좌 번호, 거래 금액
- 정책: 사용자가 없는 경우 사용자 아이디와 계좌 소유주가 다른 경우, 계좌가 이미 해지 상태인 경우, 거래 금액이 잔액보다 큰 경우, 거래 금액이 너무 작거나 큰 경우 실패 응답.
- 기술적 정책: 해당 계좌에서 거래가 진행 중인 경우, 다른 거래 요청이 오는 경우 해당 거래가 동시에 잘못 처리되는 것을 방지해야한다.
- 성공 응답: 계좌 번호, 거래 결과 코드(성공/실패), 거래 ID, 거래 금액, 거래 일시
잔액 사용 취소 API
- POST/ transaction/ cancel
- 파라미터: 거래 ID, 취소 요청 금액
- 정책: 거래 ID에 해당하는 거래가 없는 경우, 거래와 계좌가 일치하지 않는 경우, 거래 금액과 취소 금액이 다른 경우(부분 취소 불가능) 실패 응답
- 1년 넘은 거래는 사용 취소 불가능하다.
- 성공 응답: 계좌 번호, 거래 결과 코드(성공/실패), 거래 ID, 거래 금액, 거래 일시
거래 확인 API
- GET/ transaction/ {transactionId}
- 파라미터: 거래ID
- 정책: 해당 거래 ID의 거래가 없는 경우, 실패 응답한다.
- 성공 응답: 계좌 번호, 거래 종류(잔액 사용, 잔액 사용 취소), 거래 결과 코드, 거래 ID, 거래 금액, 거래 일시
- 실패한 거래도 확인할 수 있도록 한다.
8.2 설계 및 기본 구조 개발
8.2-1 패키지 구조
- aop: AOP로 중복 거래 방지 락을 걸 때, 사용될 어노테이션 등을 위치시킨다.
- config: redis 관련 설정 및 클라이언트 빈 등록, jpa 관련 설정 등록한다.
- controler: API endpoint를 등록하고 요청/ 응답의 형식을 갖는 클래스 패키지
- domain: jpa entity (테이블 구조와 동일하다.)
- dto: DTO(Data Transfer Object)를 위치시키는 곳
-> Controller에서 요청/ 응답에 사용할 클래스
-> 로직 내부에서 데이터 전송에 사용할 클래스
- exception: 커스텀 Exception, Exception Handler 클래스 패키지
- repository: Repository(DB에 연결할 때 사용하는 인터페이스)가 위치하는 패키지
- service: 비즈니스 로직을 담는 서비스 클래스 패키지
- type: 상태 타입, 에러 코드, 거래 종류 등의 다양한 enum class 들의 패키지
8.3 계좌 생성 API
Ex)
- AccountController 클래스 (수정 전)
<hide/>
package com.example.account.controller;
import com.example.account.domain.Account;
import com.example.account.dto.CreateAccount;
import com.example.account.service.AccountService;
import com.example.account.service.RedisTestService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RequiredArgsConstructor
@RestController // 컨트롤러가 빈으로 등록
public class AccountController {
private final RedisTestService redisTestService;
private final AccountService accountService;
//
@PostMapping("/account") // 최종 합의한 문서에서 post로 어카운트 할 거니까
public CreateAccount.Response createAccount(
@RequestBody @Valid CreateAccount.Request request){ // 초기 잔액을 넣는다.
accountService.createAccount();
return "success";
}
@GetMapping("/get-lock") // 락을 획득해온다.
public String getLock(){
return redisTestService.getLock();
}
@GetMapping("/account/{id}")
public Account getAccount(
@PathVariable Long id){
return accountService.getAccount(id);
}
}
- CreateAccount 클래스 만든다.
<hide/>
package com.example.account.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
public class CreateAccount {
@Getter
@Setter
public static class Request{
private Long userId;
private Long initialBalance;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public static class Response{
private Long userId;
private String accountNumber;
private LocalDateTime registeredAt;
}
}
-> H2 DB의 경우, user 테이블과 충돌염려가 있어서 클래스 이름을 AccountUser라고 만드는 게 좋다.
- domaon 패키지 클래스
<hide/>
package com.example.account.domain;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Account {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private AccountUser accountUser;
private String accountNumber;
@Enumerated(EnumType.STRING)
// String을 붙여줘야 0, 1, 2, .. 과 같은 숫자가 아닌
// AccountStatus에 있는 문자대로 DB에 저장
private AccountStatus accountStatus;
private Long balance;
private LocalDateTime registeredAt;
private LocalDateTime unregisteredAt;
@CreatedDate // 자동으로 저장
private LocalDateTime createdAt;
@LastModifiedDate // 자동으로 바꿔준다.
private LocalDateTime updatedAt;
}
<hide/>
package com.example.account.domain;
import lombok.*;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class AccountUser {
@Id
@GeneratedValue
private Long id;
private String name;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
- JpaAuditingConfiguration 클래스 만든다.
-> @EnableJpaAuditing: JpaAuditing이 켜진 상태로 스프링 어플리케이션이 뜨게 된다.
- AccountUserRepositiry 인터페이스 만든다.
- AccountService
-> orElseThrow를 통해서 사용자가 없으면 RuntimeException을 발생시킨다.
- 새로운 Exception 패키지를 만들고커스텀으로 AccountException을 만든다.
<hide/>
package com.example.account.exception;
import com.example.account.type.ErrorCode;
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class AccountException extends RuntimeException{
private ErrorCode errorCode;
private String errorMassage;
public AccountException(ErrorCode errorCode) {
this.errorCode = errorCode;
}
}
- 열거형 ErrorCode을 만든다.
-> enum에도 설명을 넣을 수 있다.
<hide/>
package com.example.account.type;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
USED_NOT_FOUND("사용자가 없습니다.");
private String description;
}
- AccountRepository
<hide/>
package com.example.account.repository;
import com.example.account.domain.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findFirstByOrderByIdDesc();
//형식에 맟줘 이름을 지으면 형태에 맞춰 쿼리 짜준다.
}
- AccountService - 계좌 생성 완료
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountRepository;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
@Service // 서비스 타입 빈으로 스프링에 자동 등록해주기 위해서 붙인다.
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final AccountUserRepository accountUserRepository;
/**
* 사용자가 있는지 조회
* 계좌의 번호를 생성한다.
* 계좌를 저장하고 그 정보를 넘긴다.
*/
// 무조건 생성자에 들어가는 값
@Transactional
public Account createAccount(Long userId, Long initialBalance){ // 테이블에 데이터를 저장한다.
AccountUser accountUser = accountUserRepository.findById(userId). // 아이디를 조회해서 사용자 있는지 확인
orElseThrow(() -> new AccountException(ErrorCode.USED_NOT_FOUND)); // 사용자가 없는 경우
String newAccountNumber = accountRepository.findFirstByOrderByIdDesc()
.map(account -> (Integer.parseInt(account.getAccountNumber())) + 1 + "")
.orElse("1000000000"); // 계좌가 없는 경우
// 가장 마지막에 생성된 계좌번호 + 1 해서 넣어 준다.
// 문자 => 숫자 => 문자
Account savedAccount = accountRepository.save(
Account.builder()
.accountUser(accountUser)
.accountStatus(AccountStatus.IN_USE)
.accountNumber(newAccountNumber)
.registeredAt(LocalDateTime.now())
.build()
) ;
return savedAccount;
}
@Transactional
public Account getAccount(Long id) { // 계좌를 조회한다.
if(id < 0){
throw new RuntimeException("Minus");
}
return accountRepository.findById(id).get();
}
}
- accountDto(엔티티 클래스와 비슷한데 단순화시킨 모양)를 만들어서 응답을 주도록 만든다.
<hide/>
package com.example.account.dto;
import com.example.account.domain.Account;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccountDto {
private Long userId;
private String accountNumber;
private Long balance;
private LocalDateTime registererAt;
private LocalDateTime unregisteredAt;
public static AccountDto fromEntity(Account account){
return AccountDto.builder()
.userId(account.getAccountUser().getId())
.accountNumber(account.getAccountNumber())
.registererAt(account.getRegisteredAt())
.unregisteredAt(account.getUnregisteredAt())
.build();
}
}
- AccountController와 AccountService 간에 통신하기 위해 AccountDto를 쓴다.
- cratedAccount클래스
<hide?>
package com.example.account.dto;
import lombok.*;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
public class CreateAccount {
@Getter
@Setter
public static class Request{
@NotNull // 어떻게 밸리드 해야할지 달아준다
@Min(1) // userId는 long인데 0은 없다고 가정
private Long userId;
@NotNull
@Min(100)
private Long initialBalance; // 초기 잔고
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Response{
private Long userId;
private String accountNumber;
private LocalDateTime registeredAt;
public static Response from(AccountDto accountDto){ // dto에서 CreateAccount로 변환해준다.
return Response.builder()
.userId(accountDto.getUserId())
.accountNumber(accountDto.getAccountNumber())
.registeredAt(accountDto.getRegistererAt())
.build();
}
}
}
- AccountController 클래스
<hide/>
package com.example.account.controller;
import com.example.account.domain.Account;
import com.example.account.dto.AccountDto;
import com.example.account.dto.CreateAccount;
import com.example.account.service.AccountService;
import com.example.account.service.RedisTestService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RequiredArgsConstructor
@RestController // 컨트롤러가 빈으로 등록
public class AccountController {
private final RedisTestService redisTestService;
private final AccountService accountService;
@PostMapping("/account") // 최종 합의한 문서에서 post로 어카운트 할 거니까
public CreateAccount.Response createAccount(
@RequestBody @Valid CreateAccount.Request request){ // 요청이 바디로 들어온다.
return CreateAccount.Response.from(accountService.createAccount(
request.getUserId(),
request.getInitialBalance()));
}
@GetMapping("/get-lock") // 락을 획득해온다.
public String getLock(){
return redisTestService.getLock();
}
@GetMapping("/account/{id}")
public Account getAccount(
@PathVariable Long id){
return accountService.getAccount(id);
}
}
<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json
{
"userId": 1,
"initialBalance": 10000
}
Note) 실행 결과
- accountId로 검색해보니까 당연히 id가 없다. AccountException 발생한다.
- data.sql을 만든다 (세미 콜론 세 개만 쓴다! 각 줄마다 쓰면 에러가 나서 서버가 뜨지도 않는다..내 얘기..)
<hide/>
INSERT INTO account_user(id, name, created_at, updated_at)
VALUES (1, 'Pororo', now(), now());
INSERT INTO account_user(id, name, created_at, updated_at)
VALUES (2, 'Lupi', now(), now());
INSERT INTO account_user(id, name, created_at, updated_at)
VALUES (3, 'Eddie', now(), now());
Note) 실행 결과
- 서버를 실행하고 들어간다.
- Account.http 파일을 만든다.
<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json
{
"userId": 1,
"initialBalance": 10000
}
Note) 실행 결과
- 계좌가 잘 만들어진다.
- 한 번 더 누르면
- 이런 식으로 계좌번호가 1씩 증가한다.
8.3-4
- AccountController 클래스의 일부에 @Mockbean으로 등록해서 모킹해준다.원하는대로 동작하도록 모킹한다. 모킹된 빈들이 어카운트 컬트롤러와 함께 테스트 컨테이너에 올라간다.
-> 테스트 코드지만 JsonProcessing 예외를 달아줘야한다.
<hide/>
package com.example.account.controller;
import com.example.account.domain.Account;
import com.example.account.dto.AccountDto;
import com.example.account.dto.CreateAccount;
import com.example.account.type.AccountStatus;
import com.example.account.service.AccountService;
import com.example.account.service.RedisTestService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
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.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// mock mvc를 만들어서 제공해준다.
@WebMvcTest (AccountController.class)
class AccountControllerTest {
@MockBean // 가짜로 빈 등록
private AccountService accountService;
@MockBean // 가짜
private RedisTestService redisTestService;
@Autowired
// redisTestService, accountService가 AccountController안으로 주입된다.
// 주입된 어플리케이션 상대로 mockMvc가 요청을 날려서 테스트
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper; // Obj <=> json
@Test
void successCreateAccount() throws Exception{
// given
given(accountService.createAccount(anyLong(), anyLong()))
.willReturn(AccountDto.builder()
.userId(1L)
.accountNumber("12345678")
.registererAt(LocalDateTime.now())
.unregisteredAt(LocalDateTime.now())
.build());
// when
// then
// content에 문자 그대로 넣을 수도 있다. json형태로
mockMvc.perform(post("/account")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CreateAccount.Request(3333L, 1111L)
)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(1))
// 응답 바디에 오는 제이슨 경로에 받는다?
.andExpect(jsonPath("$.accountNumber").value("12345678"))
.andDo(print());
}
@Test
void successGetAccount() throws Exception {
// given
given(accountService.getAccount(anyLong()))
.willReturn(Account.builder()
.accountNumber("3456")
.accountStatus(AccountStatus.IN_USE)
.build()); // 모킹된 상태
// when
// then
mockMvc.perform(get("/account/876")) // 테스트 하려는 url
.andDo(print())
.andExpect(jsonPath("$.accountNumber").value("3456")) // 바디의 첫 번째 구조에 있는 값이
.andExpect(jsonPath("$.accountStatus").value("IN_USE"))
.andExpect(status().isOk());
}
}
Note) 실행 결과 - 성공 case
8.3-5
Ex)
- AccountServiceTest클래스 수정한다.. 전에 작성 내용 모두 지운다.
-> AccountService 클래스를 보면서 작성한다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
private AccountRepository accountRepository;
// 가짜로 만들어서 넣어준다.
@Mock
private AccountUserRepository accountUserRepository;
@InjectMocks
private AccountService accountService; // 위 두 개의 Mock이 달려있다.
@Test
void createAccountSuccess(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountNumber("1000000012").build()));
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000013").build());
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000013", accountDto.getAccountNumber());
}
}
Note) 실행 결과
- 처음에는 NullPointerException이 발생했지만 AccoutUserRepository변수 위에 @Mock을 빼 먹어서 그랬다.
- 이전 내용에다가 Captor 관련 내용을 추가한다. (틀린 값을 매개변수로 넣는다.)
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
private AccountRepository accountRepository;
// 가짜로 만들어서 넣어준다.
@Mock
private AccountUserRepository accountUserRepository;
@InjectMocks
private AccountService accountService; // 위 두 개의 Mock이 달려있다.
@Test
void createAccountSuccess(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountNumber("1000000012").build()));
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000012",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
}
Note) 실행 결과
- 12로 예상했는데 실제로는 13이었다는 뜻이다.
- 맨 아래의 매개 변수에 맞는 값(13을 넣으면)?
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
private AccountRepository accountRepository;
// 가짜로 만들어서 넣어준다.
@Mock
private AccountUserRepository accountUserRepository;
@InjectMocks
private AccountService accountService; // 위 두 개의 Mock이 달려있다.
@Test
void createAccountSuccess(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountNumber("1000000012").build())); // 현재 마지막으로 저장된 계좌 번호
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000013",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
}
Note) 실행 결과
8.3-6
Ex)
- CreateAccountServic () 메서드를 참고해서 CrateFirstAccount() 메서드를 만든다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.repository.AccountRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
private AccountRepository accountRepository;
// 가짜로 만들어서 넣어준다.
@Mock
private AccountUserRepository accountUserRepository;
@InjectMocks
private AccountService accountService; // 위 두 개의 Mock이 달려있다.
@Test
void createAccountSuccess(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountNumber("1000000012").build())); // 현재 마지막으로 저장된 계좌 번호
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000013",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
@Test
void createFirstAccount(){
// given
AccountUser user = AccountUser.builder()
.id(15L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.empty()); // 기존에 계좌가 없는 상황
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(15L, accountDto.getUserId());
assertEquals("1000000000",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
}
Note) 실행 결과
- AccountService에 작성한대로 계좌가 없을 때 반환되는 "1000000000"가 잘 출력되서 테스트 성공한다.
Ex) User가 없는 경우
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.repository.AccountRepository;
import com.example.account.type.ErrorCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class AccountServiceTest {
@Mock
private AccountRepository accountRepository;
// 가짜로 만들어서 넣어준다.
@Mock
private AccountUserRepository accountUserRepository;
@InjectMocks
private AccountService accountService; // 위 두 개의 Mock이 달려있다.
@Test
void createAccountSuccess(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountNumber("1000000012").build())); // 현재 마지막으로 저장된 계좌 번호
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000013",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
@Test
void createFirstAccount(){
// given
AccountUser user = AccountUser.builder()
.id(15L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findFirstByOrderByIdDesc())
.willReturn(Optional.empty()); // 기존에 계좌가 없는 상황
given(accountRepository.save(any()))
.willReturn(Account.builder()
.accountUser(user)
.accountNumber("1000000015").build());
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
// when
AccountDto accountDto = accountService.createAccount(1L, 1000L);
// then
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(15L, accountDto.getUserId());
assertEquals("1000000000",captor.getValue().getAccountNumber()); // 캡처한 정보 어카운트
}
//유저가 없는 경우
@Test
@DisplayName("해당 유저 없음 - 계좌 생성 실패")
void createAccount_UserNotFound(){
// given
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.createAccount(1L, 1000L));
// then
assertEquals(ErrorCode.USED_NOT_FOUND, exception.getErrorCode() );
}
}
Note) 실행 결과 - User가 없는 경우
Ex) 계좌가 10개 인 경우, 계좌 생성 실패
- 인터페이스 수정
<hide/>
package com.example.account.repository;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findFirstByOrderByIdDesc();
//형식에 맟줘 이름을 지으면 형태에 맞춰 쿼리 짜준다.
Integer countByAccountUser(AccountUser accountUser);
}
- enum 수정
<hide/>
package com.example.account.type;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public enum ErrorCode {
USED_NOT_FOUND("사용자가 없습니다."),
MAX_ACCOUNT_PER_USER_10("사용자 최대 계좌는 10개입니다.");
private String description;
}
- AccountService에 메서드를 만든다.계좌 수 10개 넘으면 Exception을 발생시킨다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.AccountDto;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountRepository;
import com.example.account.repository.AccountUserRepository;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
@Service // 서비스 타입 빈으로 스프링에 자동 등록해주기 위해서 붙인다.
@RequiredArgsConstructor
public class AccountService {
private final AccountRepository accountRepository;
private final AccountUserRepository accountUserRepository;
/**
* 사용자가 있는지 조회
* 계좌의 번호를 생성한다.
* 계좌를 저장하고 그 정보를 넘긴다.
*/
// 무조건 생성자에 들어가는 값
@Transactional
public AccountDto createAccount(Long userId, Long initialBalance){ // 테이블에 데이터를 저장한다.
AccountUser accountUser = accountUserRepository.findById(userId). // 아이디를 조회해서 사용자 있는지 확인
orElseThrow(() -> new AccountException(ErrorCode.USED_NOT_FOUND)); // 사용자가 없는 경우
validateCreateAccount(accountUser);
String newAccountNumber = accountRepository.findFirstByOrderByIdDesc()
.map(account -> (Integer.parseInt(account.getAccountNumber())) + 1 + "")
.orElse("1000000000"); // 계좌가 없는 경우
// 가장 마지막에 생성된 계좌번호 + 1 해서 넣어 준다.
// 문자 => 숫자 => 문자
return AccountDto.fromEntity( // accountDto를 만들어서 리턴한다.
accountRepository.save(
Account.builder()
.accountUser(accountUser)
.accountStatus(AccountStatus.IN_USE)
.accountNumber(newAccountNumber)
.registeredAt(LocalDateTime.now())
.build()
));
}
private void validateCreateAccount(AccountUser accountUser) {
if(accountRepository.countByAccountUser(accountUser) == 10){
throw new AccountException(ErrorCode.MAX_ACCOUNT_PER_USER_10);
}
}
@Transactional
public Account getAccount(Long id) { // 계좌를 조회한다.
if(id < 0){
throw new RuntimeException("Minus");
}
return accountRepository.findById(id).get();
}
}
- Test 클래스 내용 추가
<hide/>
@Test
@DisplayName("User 당 최대 계좌 10개")
void createAccount_maxAccountIs10() {
// given
AccountUser user = AccountUser.builder()
.id(15L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.countByAccountUser(any()))
.willReturn(10);
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.createAccount(1l, 1000L));
// then
assertEquals(ErrorCode.MAX_ACCOUNT_PER_USER_10, exception.getErrorCode());
}
Note) 실행 결과
- 최대 개수 10개 확인
- Display안에 주석 부분이 깨진다.
Ex) 테스트
<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json
{
"userId": 1,
"initialBalance": 10000
}
- 테스트 하기위해 계좌를 10개 만든다.
Note) 실행 결과
- 10개 넘는 순간 부터는 에러가난다.
- localhost:8080/h2-console 화면
- userId = 2를 넣으면 다음과 같이 잘 들어간다.
8.4 계좌 해지 API
Ex) delete관련해서 AccountService, Controller 등 클래스에 작성하기
Note) 실행 결과 - delete
- AccountService에서 create할 때 계좌의 초깃값을 넣어줘야 오류가 안 난다.
Ex) AccountControllerTest에 successDeleteAccount를 만든다
<hide/>
@Test
void successDeleteAccount() throws Exception{
// given
given(accountService.deleteAccount(anyLong(), anyString()))
.willReturn(AccountDto.builder()
.userId(1L)
.accountNumber("1234567890")
.registererAt(LocalDateTime.now())
.unregisteredAt(LocalDateTime.now())
.build());
// when
// then
// content에 문자 그대로 넣을 수도 있다. json형태로
mockMvc.perform(delete("/account")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new DeleteAccount.Request(3333L, "0987654321")
)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.userId").value(1))
// 응답 바디에 오는 제이슨 경로에 받는다?
.andExpect(jsonPath("$.accountNumber").value("1234567890"))
.andDo(print());
}
Note) 실행 결과
Ex) 해지 성공 케이스
<hide/>
@Test
void deleteAccountSuccess() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.balance(0L) // 0으로 mocking
.accountNumber("1000000012").build()));
ArgumentCaptor<Account> captor = ArgumentCaptor.forClass(Account.class);
//when
AccountDto accountDto = accountService.deleteAccount(1L, "1234567890");
//tHen
verify(accountRepository, times(1)).save(captor.capture());
assertEquals(12L, accountDto.getUserId());
assertEquals("1000000012", captor.getValue().getAccountNumber());
assertEquals(AccountStatus.UNREGISTERED, captor.getValue().getAccountStatus());
}
Note) 실행 결과 - 성공 케이스
Ex) 해지 실패 케이스
<hide/>
@Test
@DisplayName("해당 유저 없음 - 계좌 해지 실패")
void deleteAccount_UserNotFound(){
// given
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.deleteAccount(1L, "1234567890"));
// then
assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과
Ex) User는 있는데 계좌가 없을 때
<hide/>
@Test
@DisplayName("해당 계좌 없음 - 계좌 해지 실패")
void deleteAccount_AccountNotFound() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.deleteAccount(1L, "1234567890"));
// then
assertEquals(ErrorCode.ACCOUNT_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과
Ex) 계좌 소유주가 달라서 에러 나는 경우 - 포비가 해리의 계좌 해지하려할 때
============오류 =========
<hide/>
@Test
@DisplayName("계좌 소유주 다름")
void deleteAccountFailed_userUnMatch() throws Exception{
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
AccountUser harry = AccountUser.builder()
.id(13L)
.name("Harry").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(harry)
.balance(0L) // 0으로 mocking
.accountNumber("1000000012").build()));
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.deleteAccount(1L, "1234567890"));
// then
assertEquals(ErrorCode.USER_ACCOUNT_UN_MATCH, exception.getErrorCode());
}
=============================== 오류 디버깅 화면 ======================================
Ex)
<hide/>
@Test
@DisplayName("해지 계좌는 잔액이 없어야 한다.")
void deleteAccountFailed_balanceNotEmpty() throws Exception{
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(Pobi)
.balance(100L) // 잔액 남으
.accountNumber("1000000012").build()));
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.deleteAccount(1L, "1234567890"));
// then
assertEquals(ErrorCode.BALANCE_NOT_EMPTY, exception.getErrorCode());
}
Note) 실행 결과
Ex)
<hide/>
@Test
@DisplayName("해지 계좌는 해지할 수 없다.")
void deleteAccountFailed_alreadyUnRegistered() {
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(Pobi)
.accountStatus(AccountStatus.UNREGISTERED)
.balance(0L)
.accountNumber("1000000012").build()));
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.deleteAccount(1L, "1234567890"));
// then
assertEquals(ErrorCode.ACCOUNT_ALREADY_UNREGISTERED, exception.getErrorCode());
}
Note) 실행 결과
8.5 계좌 확인 API
- dto는 컨트롤러 <=> 서비스 간에 정보를 주고 받는다.
- AccountInfo는 몇 가지 정보만 사용자한테 응답을 준다.
Ex)
- AccountService에 getAccountByUserId 메서드를 만든다.
<hide/>
@GetMapping("/account")
public List<AccountInfo> getAccountByUserId(
@RequestParam("user_id") Long userId
){
return accountService.getAccountByUserId(userId)
.stream().map(accountDto ->
AccountInfo.builder()
.accountNumber(accountDto.getAccountNumber())
.balance(accountDto.getBalance())
.build())
.collect(Collectors.toList());
}
- AccountRepository 클래스에 내용 추가한다.
List<Account> findByAccountUser(AccountUser accountUser);
GET http://localhost:8080/account?user_id=1
Accept: application/json
Note) 실행 결과: 공백 리스트가 나온다. 아직 만든 계좌가 없기 때문이다.
- 아이디를 세 개 만들고서 get 누르면?
-> 밸런스가 제대로 안 나온다.
- DB 창에는 잘 뜨는데? 왜 위에 오류가 날까?
-> AccountDto에서 반환부분에서 balance를 담아주는 부분이 없다. 추가하기
- 아래처럼 dto클래스에 내용을 추가한다.
<hide/>
package com.example.account.dto;
import com.example.account.domain.Account;
import com.example.account.type.AccountStatus;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccountDto {
private Long userId;
private String accountNumber;
// private AccountStatus accountStatus;
private Long balance;
private LocalDateTime registererAt;
private LocalDateTime unregisteredAt;
public static AccountDto fromEntity(Account account){
return AccountDto.builder()
.userId(account.getAccountUser().getId())
.accountNumber(account.getAccountNumber())
.balance(account.getBalance())
// .accountStatus(account.getAccountStatus())
.registererAt(account.getRegisteredAt())
.unregisteredAt(account.getUnregisteredAt())
.build();
}
}
-그 다음에 다시 아이디를 여러 개 만들고 get을 한다.
===========오류 화면 ============
- balance에 초기 잔액이 떠야 하는데 null 이 뜬다.
- 새로운응답 테스트를 만든다.
<hide/>
@Test
void successGetAccountsByUserId() throws Exception {
// given
List<AccountDto> accountDtos =
Arrays.asList(
AccountDto.builder()
.accountNumber("1234567890")
.balance(1000L).build(),
AccountDto.builder()
.accountNumber("1111111111")
.balance(2000L).build(),
AccountDto.builder()
.accountNumber("2222222222")
.balance(3000L).build()
);
given(accountService.getAccountByUserId(anyLong()))
.willReturn(accountDtos);
// when
// then
mockMvc.perform(get("/account?user_id=1"))
.andDo(print())
.andExpect(jsonPath("$[0].accountNumber").value("1234567890"))
.andExpect(jsonPath("$[0].balance").value(1000))
.andExpect(jsonPath("$[1].accountNumber").value("1111111111"))
.andExpect(jsonPath("$[1].balance").value(2000))
.andExpect(jsonPath("$[2].accountNumber").value("2222222222"))
.andExpect(jsonPath("$[2].balance").value(3000));
}
Note) 실행 결과
- url템플릿 란에 물음표 하나만 적어야 오류가 안 난다..
- AccountServiceTest에 내용 추가
<hide/>
@Test
void successGetAccountsByUserId(){
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
List<Account> accounts = Arrays.asList(
Account.builder()
.accountUser(Pobi)
.accountNumber("1111111111")
.balance(1000L)
.build(),
Account.builder()
.accountUser(Pobi)
.accountNumber("2222222222")
.balance(2000L)
.build(),
Account.builder()
.accountUser(Pobi)
.accountNumber("3333333333")
.balance(3000L)
.build()
);
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountUser(any()))
.willReturn(accounts);
// when
List<AccountDto>accountDtos = accountService.getAccountByUserId(1L);
// then
assertEquals(3, accountDtos.size());
assertEquals("1111111111", accountDtos.get(0).getAccountNumber());
assertEquals(1000, accountDtos.get(0).getBalance());
assertEquals("2222222222", accountDtos.get(1).getAccountNumber());
assertEquals(2000, accountDtos.get(1).getBalance());
assertEquals("3333333333", accountDtos.get(2).getAccountNumber());
assertEquals(3000, accountDtos.get(2).getBalance());
}
Note) 실행 결과
Ex) failedToGetAccounts()
<hide/>
@Test
void failedToGetAccounts(){
// given
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> accountService.getAccountByUserId(1L));
// then
assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과
8.6 잔액 사용 API
-
Ex)
- 컨트롤러 하위에 Transaction Controller를 하나 만든다.
- dto 아래에 UseBalance 클래스 => 잔액 사용
-> 하위에 Request와 Response 클래스를 만든다.
<hide/>
package com.example.account.dto;
import com.example.account.type.TransactionResultType;
import lombok.*;
import javax.validation.constraints.*;
import java.time.LocalDateTime;
public class UseBalance {
@Getter
@Setter
@AllArgsConstructor
public static class Request{
@NotNull // 어떻게 밸리드 해야할지 달아준다
@Min(1) // userId는 long인데 0은 없다고 가정
private Long userId;
@NotBlank
@Size(min = 10, max = 10)
private String accountNumber;
@NotBlank
@Min(10)
@Max(1000_000_000)
private Long amount;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Response{
private Long userId;
private TransactionResultType transactionResultType;
private String transactionId;
private Long amount;
private LocalDateTime transactedAt;
}
}
- TransactionResultType으로 S, F를 만든다.
- domain아래에 Transaction클래스를 만든다.
<hide/>
package com.example.account.domain;
import com.example.account.type.AccountStatus;
import com.example.account.type.TransactionResultType;
import com.example.account.type.TransactionType;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Transaction {
@Id
@GeneratedValue
private Long id;
@Enumerated(EnumType.STRING) // String 타입으로 저장한다.
private TransactionType transactionType;
@Enumerated(EnumType.STRING)
private TransactionResultType transactionResultType;
@ManyToOne
private Account account; // 여러 거래가 한 계좌에 연결된다.
private Long amount;
private Long balanceSnapshot;
private String transactionId; // id는 pk라서 직접 쓰면 위험하다.
private LocalDateTime transactedAt;
@CreatedDate // 자동으로 저장
private LocalDateTime createdAt;
@LastModifiedDate // 자동으로 바꿔준다.
private LocalDateTime updatedAt;
}
- TransactionRepository를 만든다. JpaRepository 인터페이스를 상속 받는다.
<hide/>
package com.example.account.repository;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.domain.Transaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface TransactionRepository
extends JpaRepository<Transaction, Long> {
}
- TransactionType으로 USE, CANCEL을 만든다.
- TransactionService 클래스를 만든다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.dto.TransactionDto;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountRepository;
import com.example.account.repository.AccountUserRepository;
import com.example.account.repository.TransactionRepository;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.util.Objects;
@Slf4j
@Service
@RequiredArgsConstructor
public class TransactionService {
private final TransactionRepository transactionRepository;
private final AccountUserRepository accountUserRepository;
private final AccountRepository accountRepository;
/*
사용자가 없는 경우, 사용자 아이디와 계좌 소유주가 다른 경우,
계좌가 이미 해지 상태인 경우, 거래 금액이 잔액보다 큰 경우,
거래 금액이 너무 적거나 큰 경우 실패 응답
*/
@Transactional
public TransactionDto useBalance(Long userId, String accountNumber, Long amount){
AccountUser user = accountUserRepository.findById(userId)
.orElseThrow(() -> new AccountException(ErrorCode.USER_NOT_FOUND));
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow( ()-> new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));
validationUseBalance(user, account, amount);
// 반환형 넣을 예정
}
private void validationUseBalance(AccountUser user, Account account, Long amount) {
if (!Objects.equals(user.getId(), account.getAccountUser().getId())) {
// 널에 대한 안전한 비교 방법이다.
throw new AccountException(ErrorCode.USER_ACCOUNT_UN_MATCH);
}
if (account.getAccountStatus() != AccountStatus.IN_USE) {
throw new AccountException(ErrorCode.ACCOUNT_ALREADY_UNREGISTERED);
}
if (account.getBalance() < amount) { // 잔고보다 많이 인출
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
}
}
- TransactionDto클래스 만들기
<hide/>
package com.example.account.dto;
import com.example.account.domain.Account;
import com.example.account.type.TransactionResultType;
import com.example.account.type.TransactionType;
import lombok.*;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TransactionDto {
private String accountNumber; // useBalance의 Response에 AccountNumber가 있다.
private TransactionType transactionType;
private TransactionResultType transactionResultType;
private Long amount;
private Long balanceSnapshot;
private String transactionId; // id는 pk라서 직접 쓰면 위험하다.
private LocalDateTime transactedAt;
}
- Account클래스에 내용을 추가한다.
-> 잔액 변경하는 로직은 객체 안에서 직접 수행하도록 하면 좋은 방법이다.
<hide/>
package com.example.account.domain;
import com.example.account.exception.AccountException;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Account {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private AccountUser accountUser;
private String accountNumber;
@Enumerated(EnumType.STRING)
// String을 붙여줘야 0, 1, 2, .. 과 같은 숫자가 아닌
// AccountStatus에 있는 문자대로 DB에 저장
private AccountStatus accountStatus; // 계좌 상태
private Long balance; // 잔액
private LocalDateTime registeredAt;
private LocalDateTime unregisteredAt;
@CreatedDate // 자동으로 저장
private LocalDateTime createdAt;
@LastModifiedDate // 자동으로 바꿔준다.
private LocalDateTime updatedAt;
public void useBalance(Long amount){
if(amount > balance){
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
balance -= amount;
}
}
- TransactionDto클래스
<hide/>
package com.example.account.dto;
import com.example.account.domain.Account;
import com.example.account.domain.Transaction;
import com.example.account.type.TransactionResultType;
import com.example.account.type.TransactionType;
import lombok.*;
import javax.persistence.EnumType;
import javax.persistence.Enumerated;
import javax.persistence.ManyToOne;
import java.time.LocalDateTime;
@Setter
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TransactionDto {
private String accountNumber; // useBalance의 Response에 AccountNumber가 있다.
private TransactionType transactionType;
private TransactionResultType transactionResultType;
private Long amount;
private Long balanceSnapshot;
private String transactionId; // id는 pk라서 직접 쓰면 위험하다.
private LocalDateTime transactedAt;
public static TransactionDto fromEntity(Transaction transaction){
return TransactionDto.builder()
.accountNumber(transaction.getAccount().getAccountNumber())
.transactionType(transaction.getTransactionType())
.transactionResultType(transaction.getTransactionResultType())
.amount(transaction.getAmount())
.balanceSnapshot(transaction.getBalanceSnapshot())
.transactionId(transaction.getTransactionId())
.transactedAt(transaction.getTransactedAt())
.build();
}
}
- Transaction 클래스
<hide/>
package com.example.account.domain;
import com.example.account.type.AccountStatus;
import com.example.account.type.TransactionResultType;
import com.example.account.type.TransactionType;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Transaction {
@Id
@GeneratedValue
private Long id;
@Enumerated(EnumType.STRING) // String 타입으로 저장한다.
private TransactionType transactionType;
@Enumerated(EnumType.STRING)
private TransactionResultType transactionResultType;
@ManyToOne
private Account account; // 여러 거래가 한 계좌에 연결된다.
private Long amount;
private Long balanceSnapshot;
private String transactionId; // id는 pk라서 직접 쓰면 위험하다.
private LocalDateTime transactedAt;
@CreatedDate // 자동으로 저장
private LocalDateTime createdAt;
@LastModifiedDate // 자동으로 바꿔준다.
private LocalDateTime updatedAt;
}
- TransactionSevice 클래스
-> transaction ID로 고유한 값을 만들기 위한 방법은 UUID(universial unique identified)를 쓰면 가장 편리히고 쉽다.
-> transaction이 저장되지 않으면 Account도 잔액이 업데이트 되지 않는다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.domain.Transaction;
import com.example.account.dto.TransactionDto;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountRepository;
import com.example.account.repository.AccountUserRepository;
import com.example.account.repository.TransactionRepository;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
import static com.example.account.type.TransactionResultType.S;
import static com.example.account.type.TransactionType.USE;
@Slf4j
@Service
@RequiredArgsConstructor
public class TransactionService {
private final TransactionRepository transactionRepository;
private final AccountUserRepository accountUserRepository;
private final AccountRepository accountRepository;
/*
사용자가 없는 경우, 사용자 아이디와 계좌 소유주가 다른 경우,
계좌가 이미 해지 상태인 경우, 거래 금액이 잔액보다 큰 경우,
거래 금액이 너무 적거나 큰 경우 실패 응답
*/
@Transactional
public TransactionDto useBalance(Long userId, String accountNumber, Long amount){
AccountUser user = accountUserRepository.findById(userId)
.orElseThrow(() -> new AccountException(ErrorCode.USER_NOT_FOUND));
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow( ()-> new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));
validationUseBalance(user, account, amount);
account.useBalance(amount);
Transaction transaction = transactionRepository.save(
Transaction.builder()
.transactionType(USE)
.transactionResultType(S)
.account(account)
.amount(amount)
.balanceSnapshot(account.getBalance())
.transactionId(UUID.randomUUID().toString().replace("-", ""))
.transactedAt(LocalDateTime.now())
.build()
);
return TransactionDto.fromEntity(transaction)
}
private void validationUseBalance(AccountUser user, Account account, Long amount) {
if (!Objects.equals(user.getId(), account.getAccountUser().getId())) {
// 널에 대한 안전한 비교 방법이다.
throw new AccountException(ErrorCode.USER_ACCOUNT_UN_MATCH);
}
if (account.getAccountStatus() != AccountStatus.IN_USE) {
throw new AccountException(ErrorCode.ACCOUNT_ALREADY_UNREGISTERED);
}
if (account.getBalance() < amount) { // 잔고보다 많이 인출
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
}
}
- UseBalance 클래스
<hide/>
package com.example.account.dto;
import com.example.account.type.TransactionResultType;
import lombok.*;
import javax.validation.constraints.*;
import java.time.LocalDateTime;
public class UseBalance {
@Getter
@Setter
@AllArgsConstructor
public static class Request{
@NotNull // 어떻게 밸리드 해야할지 달아준다
@Min(1) // userId는 long인데 0은 없다고 가정
private Long userId;
@NotBlank
@Size(min = 10, max = 10)
private String accountNumber;
@NotBlank
@Min(10)
@Max(1000_000_000)
private Long amount;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Response{
private String accountNumber;
private TransactionResultType transactionResultType;
private String transactionId;
private Long amount;
private LocalDateTime transactedAt;
public static Response from(TransactionDto transactionDto) {
return Response.builder()
.accountNumber(transactionDto.getAccountNumber())
.transactionResultType(transactionDto.getTransactionResultType())
.transactionId(transactionDto.getTransactionId())
.amount(transactionDto.getAmount())
.transactedAt(transactionDto.getTransactedAt())
.build();
}
}
}
- Controller클래스
-> 거래 실패하는 경우도 생각해서 try-catch로 감싼다.
<hide/>
package com.example.account.controller;
import com.example.account.dto.AccountDto;
import com.example.account.dto.CreateAccount;
import com.example.account.dto.TransactionDto;
import com.example.account.dto.UseBalance;
import com.example.account.exception.AccountException;
import com.example.account.service.TransactionService;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import java.time.LocalDateTime;
/**
* 잔액 관련 컨트롤러
* 1. 잔액 사용
* 2. 잔액 사용 취소
* 3. 거래 확인
*/
@Slf4j
@RestController
@RequiredArgsConstructor
public class TransactionController {
private final TransactionService transactionService;
@PostMapping("/transaction/use")
public UseBalance.Response useBalance(
@Valid @RequestBody UseBalance.Request request
) {
try { // 거래 성공
return UseBalance.Response.from(
transactionService.useBalance(request.getUserId(),
request.getAccountNumber(), request.getAmount())
);
} catch (AccountException e) { // 거래 실패
log.error("Failed to use balance.");
transactionService.saveFailedUseTransaction( // 실패건만 저장한다.
request.getAccountNumber(),
request.getAmount()
);
throw e;
}
}
}
- TransactionService클래스
-> 메서드 추출한다.
<hide/>
package com.example.account.service;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.domain.Transaction;
import com.example.account.dto.TransactionDto;
import com.example.account.exception.AccountException;
import com.example.account.repository.AccountRepository;
import com.example.account.repository.AccountUserRepository;
import com.example.account.repository.TransactionRepository;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import com.example.account.type.TransactionResultType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;
import static com.example.account.type.TransactionResultType.F;
import static com.example.account.type.TransactionResultType.S;
import static com.example.account.type.TransactionType.USE;
@Slf4j
@Service
@RequiredArgsConstructor
public class TransactionService {
private final TransactionRepository transactionRepository;
private final AccountUserRepository accountUserRepository;
private final AccountRepository accountRepository;
/*
사용자가 없는 경우, 사용자 아이디와 계좌 소유주가 다른 경우,
계좌가 이미 해지 상태인 경우, 거래 금액이 잔액보다 큰 경우,
거래 금액이 너무 적거나 큰 경우 실패 응답
*/
@Transactional
public TransactionDto useBalance(Long userId, String accountNumber, Long amount){
AccountUser user = accountUserRepository.findById(userId)
.orElseThrow(() -> new AccountException(ErrorCode.USER_NOT_FOUND));
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow( ()-> new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));
validationUseBalance(user, account, amount);
account.useBalance(amount); // Account 테이블에 있는 해당 잔액을 비교
// 업데이트
return TransactionDto.fromEntity(saveAndGetTransaction(S, account, amount)); // 성공한 경우
}
private void validationUseBalance(AccountUser user,
Account account,
Long amount) {
if (!Objects.equals(user.getId(), account.getAccountUser().getId())) {
// 널에 대한 안전한 비교 방법이다.
throw new AccountException(ErrorCode.USER_ACCOUNT_UN_MATCH);
}
if (account.getAccountStatus() != AccountStatus.IN_USE) {
throw new AccountException(ErrorCode.ACCOUNT_ALREADY_UNREGISTERED);
}
if (account.getBalance() < amount) { // 잔고보다 많이 인출
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
}
@Transactional
public void saveFailedUseTransaction(String accountNumber,
Long amount) {
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow( () -> new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));
saveAndGetTransaction(F, account, amount); // 실패한 경우
}
private Transaction saveAndGetTransaction(TransactionResultType transactionResultType, Account account, Long amount) {
return transactionRepository.save( // useBalance에서 S만 F로 바꾼다.
Transaction.builder()
.transactionType(USE)
.transactionResultType(transactionResultType)
.account(account)
.amount(amount)
.balanceSnapshot(account.getBalance())
.transactionId(UUID.randomUUID().toString().replace("-", ""))
.transactedAt(LocalDateTime.now())
.build()
);
}
}
8.6-4
Ex)
============오류===============
<hide/>
@Test
void successUseBalance() throws Exception {
// given
given(transactionService.useBalance(anyLong(), anyString(), anyLong()))
.willReturn(TransactionDto.builder()
.accountNumber("1000000000")
.transactedAt(LocalDateTime.now())
.amount(12345L)
.transactionId("transactionId")
.transactionResultType(S)
.build());
// when
// then
mockMvc.perform(MockMvcRequestBuilders.post("/transaction/use")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new UseBalance.Request(1L, "2000000000", 3000L)
))
).andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.accountNumber").value("1000000000"))
.andExpect(jsonPath("$.transactionResult").value("S"))
.andExpect(jsonPath("$.transactionId").value("transactionId"))
.andExpect(jsonPath("$.amount").value("12345L"));
}
Note) 실행 결과
- TransactionServiceTest클래스에 successUseBalance() 만들기
<hide/>
@Test
void successUseBalance(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build()));
given(transactionRepository.save(any()))
.willReturn(Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now())
.amount(1000L)
.balanceSnapshot(9000L)
.build());
ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
// when
TransactionDto transactionDto = transactionService.useBalance(1L,
"1000000000", 200L);
// then
verify(transactionRepository, times(1)).save(captor.capture());
assertEquals(200L, captor.getValue().getAmount());
assertEquals(9800L, captor.getValue().getBalanceSnapshot());
assertEquals(S, transactionDto.getTransactionResultType());
assertEquals(USE, transactionDto.getTransactionType());
assertEquals(9000L, transactionDto.getBalanceSnapshot());
assertEquals(1000L, transactionDto.getAmount());
}
Note) 실행 결과
- useBalance_UserNotFound()
<hide/>
@Test
@DisplayName("해당 유저 없음 - 잔액 사용 실패")
void useBalance_UserNotFound(){
// given
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.useBalance(1L, "1000000000", 1000L));
// then
assertEquals(ErrorCode.USER_NOT_FOUND, exception.getErrorCode() );
}
Note) 실행 결과
- deleteAccount_AccountNotFound()
<hide/>
@Test
@DisplayName("해당 계좌 없음 - 잔액 사용 실패")
void deleteAccount_AccountNotFound() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.useBalance(1L, "1000000000", 1000L));
// then
assertEquals(ErrorCode.ACCOUNT_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과
-TransactionServiceTest클래스의 deleteAccountFailed_userUnMatch()
<hide/>
@Test
@DisplayName("계좌 소유주 다름 - 잔액 사용 실패")
void deleteAccountFailed_userUnMatch() throws Exception{
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
AccountUser harry = AccountUser.builder()
.id(13L)
.name("Harry").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(harry)
.balance(0L) // 0으로 mocking
.accountNumber("1000000012").build()));
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.useBalance(1L, "1234567890", 1000L));
// then
assertEquals(ErrorCode.USER_ACCOUNT_UN_MATCH, exception.getErrorCode());
}
Note) 실행 결과
-
<hide/>
@Test
@DisplayName("해지 계좌는 사용할 수 없다.")
void deleteAccountFailed_alreadyUnRegistered() {
// given
AccountUser Pobi = AccountUser.builder()
.id(12L)
.name("Pobi"). build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(Pobi));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(Account.builder()
.accountUser(Pobi)
.accountStatus(AccountStatus.UNREGISTERED)
.balance(0L)
.accountNumber("1000000012").build()));
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.useBalance(1L, "1234567890", 1000L));
// then
assertEquals(ErrorCode.ACCOUNT_ALREADY_UNREGISTERED, exception.getErrorCode());
}
Note) 실행 결과
- exceedAmount_UseBalance()
<hide/>
@Test
@DisplayName("거래 금액이 잔액보다 큰 경우")
void exceedAmount_UseBalance(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.accountUser(user)
.accountStatus(IN_USE)
.balance(100L)
.accountNumber("1000000012").build();
given(accountUserRepository.findById(anyLong()))
.willReturn(Optional.of(user));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(account));
ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.useBalance(1L, "1234567890", 1000L));
assertEquals(ErrorCode.AMOUNT_EXCEED_BALANCE, exception.getErrorCode());
verify(transactionRepository, times(0)).save(any());
}
Note) 실행 결과
- saveFailedUseTransaction
<hide/>
@Test
@DisplayName("실패 트랜잭션 저장 성공")
void saveFailedUseTransaction(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(account));
given(transactionRepository.save(any()))
.willReturn(Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now())
.amount(1000L)
.balanceSnapshot(9000L)
.build());
ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
// when
transactionService.saveFailedUseTransaction("1000000000", USE_AMOUNT);
// then
verify(transactionRepository, times(1)).save(captor.capture());
assertEquals(USE_AMOUNT, captor.getValue().getAmount());
assertEquals(10000L, captor.getValue().getBalanceSnapshot());
assertEquals(F, captor.getValue().getTransactionResultType());
}
Note) 실행 결과
8.7 잔액 사용 취소 API
- cancelBalance클래스를 만든다.
<hide/>
package com.example.account.dto;
import com.example.account.type.TransactionResultType;
import lombok.*;
import javax.validation.constraints.*;
import java.time.LocalDateTime;
public class CancelBalance {
@Getter
@Setter
@AllArgsConstructor
public static class Request{
@NotBlank
private String transactionId; // 난수
@NotBlank
@Size(min = 10, max = 10)
private String accountNumber;
@NotNull
@Min(10)
@Max(1000_000_000)
private Long amount;
}
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public static class Response{
private String accountNumber;
private TransactionResultType transactionResultType;
private String transactionId;
private Long amount;
private LocalDateTime transactedAt;
private Long onlyForUse;
private Long onlyForCancel;
public static Response from(TransactionDto transactionDto) {
return Response.builder()
.accountNumber(transactionDto.getAccountNumber())
.transactionResultType(transactionDto.getTransactionResultType())
.transactionId(transactionDto.getTransactionId())
.amount(transactionDto.getAmount())
.transactedAt(transactionDto.getTransactedAt())
.build();
}
}
}
- TransactionRepository 클래스 내용 추가
<hide/>
package com.example.account.repository;
import com.example.account.domain.Account;
import com.example.account.domain.AccountUser;
import com.example.account.domain.Transaction;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface TransactionRepository
extends JpaRepository<Transaction, Long> {
Optional<Transaction> findByTransactionId(String transactionId);
}
- transactionService에 cancelBalance 메서드 추가
<hide/>
@Transactional
public TransactionDto cancelBalance(String transactionId,
String accountNumber,
Long amount) {
Transaction transaction = transactionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new AccountException(ErrorCode.TRANSACTION_NOT_FOUND));
Account account = accountRepository.findByAccountNumber(accountNumber)
.orElseThrow(()-> new AccountException(ErrorCode.ACCOUNT_NOT_FOUND));
validateCancelBalance(transaction, account, amount);
account.cancelBalance(amount);
return TransactionDto.fromEntity(saveAndGetTransaction(CANCEL, S, account, amount));
}
Ex)
- create account 한다. (Post)
<hide/>
### use balance
POST http://localhost:8080/transaction/use
Content-Type: application/json
{
"userId": 1,
"accountNumber": "1000000002",
"amount": 10000
}
Note) 실행 결과
Ex) 거래 취소하기 - cancel balance
====================오류 ==================
### cancel balance
POST http://localhost:8080/transaction/cancel
Content-Type: application/json
{
"transactionId": "610264151155454ca2d069779daa0e8c",
"accountNumber": "1000000002",
"amount": 10000
}
=============오류 화면
Note) 실행 결과
- userId가 null이라서 실행이 안된다.
Ex)
<hide/>
### create account
POST http://localhost:8080/account
Content-Type: application/json
{
"userId": 1,
"initialBalance": 1234500
}
<hide/>
### use balance
POST http://localhost:8080/transaction/use
Content-Type: application/json
{
"userId": 1,
"accountNumber": "1000000000",
"amount": 10000
}
- 금액 바꿔서 한 번 더한다.
<hide/>
### use balance
POST http://localhost:8080/transaction/use
Content-Type: application/json
{
"userId": 1,
"accountNumber": "1000000000",
"amount": 20000
}
Note) 실행 결과
Ex) 위의 마지막 거래를 취소
<hide/>
### cancel balance
POST http://localhost:8080/transaction/cancel
Content-Type: application/json
{
"transactionId": "0274c2ab9d92407294910cd488eb20b0",
"accountNumber": "1000000000",
"amount": 20000
}
===============================오류 ==================================
- cancelBalancce에 request에 @No.. 어노테이션을 여도 오류난다.
Ex)
- successCancelBalance () 만들기
============오류 코드 ======
<hide/>
@Test
void successCancelBalance() throws Exception {
// given
given(transactionService.useBalance(anyLong(), anyString(), anyLong()))
.willReturn(TransactionDto.builder()
.accountNumber("1000000000")
.transactedAt(LocalDateTime.now())
.amount(54321L)
.transactionId("transactionIdForCancel")
.transactionResultType(S)
.build());
// when
// then
mockMvc.perform(post("/transaction/cancel")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(
new CancelBalance.Request("transactionId",
"2000000000",
3000L)
)))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.accountNumber").value("1000000000"))
.andExpect(jsonPath("$.transactionResultType").value("S")) // 강사님이랑 다르게 Type으로 바꾸면 통과되기도한다.
.andExpect(jsonPath("$.transactionId").value("transactionIdForCancel"))
.andExpect(jsonPath("$.amount").value(54321));
}
===============오류 화면 ==================
-given 에 use => cnancel으로 바꿔야한다.
- 수정한 다음
-> Account 클래스에 cancelBalance 구현할 때 +가 아니라 -를 넣어야한다. (그래서 10200 != 9800 오류가 계속 났다.)
<hide/>
@Test
void successCancelBalance(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
Transaction transaction = Transaction.builder()
.account(account)
.transactionType(USE) // 원래 거래는 USE, SUCCESS 였을 것이다.
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now())
.amount(CANCEL_AMOUNT)
.balanceSnapshot(9000L)
.build();
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(transaction));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(account));
given(transactionRepository.save(any()))
.willReturn(Transaction.builder()
.account(account)
.transactionType(CANCEL)
.transactionResultType(S)
.transactionId("transactionIdForCancel")
.transactedAt(LocalDateTime.now())
.amount(CANCEL_AMOUNT)
.balanceSnapshot(10000L) //
.build());
ArgumentCaptor<Transaction> captor = ArgumentCaptor.forClass(Transaction.class);
// when
TransactionDto transactionDto = transactionService.cancelBalance("transactionId",
"1000000000", CANCEL_AMOUNT);
// then
verify(transactionRepository, times(1)).save(captor.capture());
assertEquals(CANCEL_AMOUNT, captor.getValue().getAmount());
assertEquals(10000L + CANCEL_AMOUNT, captor.getValue().getBalanceSnapshot()); // 마이너스로 바꾸니까 통과
assertEquals(S, transactionDto.getTransactionResultType());
assertEquals(CANCEL, transactionDto.getTransactionType());
assertEquals(10000L, transactionDto.getBalanceSnapshot());
assertEquals(CANCEL_AMOUNT, transactionDto.getAmount());
}
<hide/>
package com.example.account.domain;
import com.example.account.exception.AccountException;
import com.example.account.type.AccountStatus;
import com.example.account.type.ErrorCode;
import lombok.*;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import javax.persistence.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Account {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private AccountUser accountUser;
private String accountNumber;
@Enumerated(EnumType.STRING)
// String을 붙여줘야 0, 1, 2, .. 과 같은 숫자가 아닌
// AccountStatus에 있는 문자대로 DB에 저장
private AccountStatus accountStatus; // 계좌 상태
private Long balance; // 잔액
private LocalDateTime registeredAt;
private LocalDateTime unregisteredAt;
@CreatedDate // 자동으로 저장
private LocalDateTime createdAt;
@LastModifiedDate // 자동으로 바꿔준다.
private LocalDateTime updatedAt;
public void useBalance(Long amount){ // 잔액 비교
if(amount > balance){
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
balance -= amount;
}
public void cancelBalance(Long amount){ // 잔액 비교
if(amount < 0){
throw new AccountException(ErrorCode.AMOUNT_EXCEED_BALANCE);
}
balance += amount;
}
}
Note) 실행 결과
Ex) cancelTransaction_AccountNotFount()
<hide/>
@Test
@DisplayName("해당 계좌 없음 - 잔액 사용 취소 실패")
void cancelTransaction_AccountNotFound() {
// given
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(Transaction.builder().build()));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.cancelBalance("transactionId", "1000000000", 1000L));
// then
assertEquals(ErrorCode.ACCOUNT_NOT_FOUND, exception.getErrorCode());
}
Ex) cancelTransaction_TransactionNotFount()
<hide/>
@Test
@DisplayName("원 사용 거래 없음 - 잔액 사용 취소 실패")
void cancelTransaction_TransactionNotFound() {
// given
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.cancelBalance("transactionId", "1000000000", 1000L));
// then
assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과
Ex) cancelTransaction_TransactionAccountNotFound()
<hide/>
@Test
@DisplayName("거래와 계좌가 매칭 실패- 잔액 사용 취소 실패")
void cancelTransaction_TransactionAccountNotFound() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.id(1L)
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
Account accountNotUse = Account.builder()
.id(2L)
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000013").build();
Transaction transaction = Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now())
.amount(CANCEL_AMOUNT)
.balanceSnapshot(9000L)
.build();
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(transaction));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(accountNotUse));
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.
cancelBalance(
"transactionId",
"1000000000",
CANCEL_AMOUNT));
// then
assertEquals(ErrorCode.TRANSACTION_ACCOUNT_UN_MATCH, exception.getErrorCode());
}
Note) 실행 결과
Ex)
<hide/>
@Test
@DisplayName("거래 금액과 취소 금액이 다름 - 잔액 사용 취소 실패")
void cancelTransaction_CancelMustFully() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.id(1L)
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
Transaction transaction = Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now())
.amount(CANCEL_AMOUNT + 1000L)
.balanceSnapshot(9000L)
.build();
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(transaction));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(account));
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.
cancelBalance(
"transactionId",
"1000000000",
CANCEL_AMOUNT));
// then
assertEquals(ErrorCode.CANCEL_MUST_FULLY, exception.getErrorCode());
}
Note) 실행 결과
Ex) cancelTransaction_TooOldOrder()
<hide/>
@Test
@DisplayName("취소는 1년 까지만 가능 - 잔액 사용 취소 실패")
void cancelTransaction_TooOldOrder() {
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.id(1L)
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
Transaction transaction = Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now().minusYears(1).minusDays(1))
.amount(CANCEL_AMOUNT)
.balanceSnapshot(9000L)
.build();
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(transaction));
given(accountRepository.findByAccountNumber(anyString()))
.willReturn(Optional.of(account));
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.
cancelBalance(
"transactionId",
"1000000000",
CANCEL_AMOUNT));
// then
assertEquals(ErrorCode.TOO_OLD_ORDER_TO_CANCEL, exception.getErrorCode());
}
Note) 실행 결과
8.8 잔액 사용 확인 API
Ex)
- QueryTransactionResponse - 응답 객체 만들기
<hide/>
package com.example.account.dto;
import com.example.account.type.TransactionResultType;
import com.example.account.type.TransactionType;
import lombok.*;
import java.time.LocalDateTime;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class QueryTransactionResponse {
private String accountNumber;
private TransactionResultType transactionResultType;
private TransactionType transactionType;
private String transactionId;
private Long amount;
private LocalDateTime transactedAt;
private Long onlyForUse;
private Long onlyForCancel;
public static QueryTransactionResponse from(TransactionDto transactionDto) {
return QueryTransactionResponse.builder()
.accountNumber(transactionDto.getAccountNumber())
.transactionType(transactionDto.getTransactionType())
.transactionResultType(transactionDto.getTransactionResultType())
.transactionId(transactionDto.getTransactionId())
.amount(transactionDto.getAmount())
.transactedAt(transactionDto.getTransactedAt())
.build();
}
}
- transactionService클래스에 queryTransaction() 추가
- 컨트롤러에 getMapping 투가
Ex)
- create account -계좌를 먼저 만든다. 세 번 만든다.
- 거래를 만든다.
<hide/>
### use balance
POST http://localhost:8080/transaction/use
Content-Type: application/json
{
"userId": 1,
"accountNumber": "1000000002",
"amount": 20000
}
- query transaction 에 오류가난단.
### query transaction
GET http://localhost:8080/transaction/6f544bc3cbdc453889c9fd4751aa5964
======================500 번 내부 서버 오류=====================
Ex)
- successQueryTransaction () 만든다.
<hide/>
@Test
void successQueryTransaction() throws Exception {
// given
given(transactionService.queryTransaction(anyString()))
.willReturn(TransactionDto.builder()
.accountNumber("1000000000")
.transactionType(USE)
.transactedAt(LocalDateTime.now())
.amount(54321L)
.transactionId("transactionIdForCancel")
.transactionResultType(S)
.build());
// when
// then
mockMvc.perform(get("/transaction/12345"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.accountNumber").value("1000000000"))
.andExpect(jsonPath("$.transactionType").value("USE"))
.andExpect(jsonPath("$.transactionResultType").value("S"))
.andExpect(jsonPath("$.transactionId").value("transactionIdForCancel"))
.andExpect(jsonPath("$.amount").value(54321));
}
Ex)
- succeseQueryTransaction을 만든다.
<hide/>
@Test
void successQueryTransaction(){
// given
AccountUser user = AccountUser.builder()
.id(12L)
.name("Pobi").build();
Account account = Account.builder()
.id(1L)
.accountUser(user)
.accountStatus(IN_USE)
.balance(10000L)
.accountNumber("1000000012").build();
Transaction transaction = Transaction.builder()
.account(account)
.transactionType(USE)
.transactionResultType(S)
.transactionId("transactionId")
.transactedAt(LocalDateTime.now().minusYears(1))
.amount(CANCEL_AMOUNT)
.balanceSnapshot(9000L)
.build();
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.of(transaction));
// when
TransactionDto transactionDto = transactionService.queryTransaction("trxId");
// then
assertEquals(USE, transactionDto.getTransactionType());
assertEquals(S, transactionDto.getTransactionResultType());
assertEquals(CANCEL_AMOUNT, transactionDto.getAmount());
assertEquals("transactionId", transactionDto.getTransactionId());
}
Note) 실행 결과
Ex) queryTransaction_TransactionNotFound
<hide/>
@Test
@DisplayName("원 거래 없음 - 거래 조회 실패")
void queryTransaction_TransactionNotFound() {
// given
given(transactionRepository.findByTransactionId(anyString()))
.willReturn(Optional.empty());
// when
AccountException exception = assertThrows(AccountException.class,
() -> transactionService.queryTransaction("transactionId"));
// then
assertEquals(ErrorCode.TRANSACTION_NOT_FOUND, exception.getErrorCode());
}
Note) 실행 결과 30여개 테스트 통과
'Spring Projcect > 계좌 관리 시스템 프로젝트' 카테고리의 다른 글
Chapter 09. Account(계좌) 시스템 업그레이드 (0) | 2022.08.05 |
---|---|
Chapter 07. 사전 준비 (0) | 2022.08.02 |
Chapter 06. 스프링 MVC(Model-View-Controller) (0) | 2022.07.27 |
Chapter 03. 자바에서 스프링으로 (0) | 2022.07.19 |
Chapter 02. OOP(Object Oriented Pragramming)와 스프링 프레임워크 (0) | 2022.07.19 |