Spring Projcect/계좌 관리 시스템 프로젝트

Chapter 08. Account(계좌) 시스템 개발

계란💕 2022. 8. 2. 13:01

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여개 테스트 통과