Skip to content

Commit 6cf1954

Browse files
authored
Merge pull request #141 from prgrms-web-devcourse-final-project/feat#136
[Feat]: 낙찰 결제 흐름 구축 + 테스트 통과
2 parents 14820ef + 93cc38c commit 6cf1954

File tree

8 files changed

+1015
-7
lines changed

8 files changed

+1015
-7
lines changed

src/main/java/com/backend/domain/bid/controller/ApiV1BidController.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,13 @@ public RsData<BidPayResponseDto> payBid(
9696
}
9797

9898
Long memberId;
99+
String username = user.getUsername();
99100
try {
100-
memberId = Long.parseLong(user.getUsername());
101+
memberId = Long.parseLong(username);
101102
} catch (NumberFormatException e) {
102-
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 인증 정보입니다.");
103+
var me = memberRepository.findByEmail(username)
104+
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "유효하지 않은 인증 정보입니다."));
105+
memberId = me.getId();
103106
}
104107

105108
return bidService.payForBid(memberId, bidId);

src/main/java/com/backend/domain/cash/service/CashService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ public CashTransaction withdraw(Member member, long amount,
175175
// 잔액 부족이면 실패..
176176
if (current < amount) {
177177
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "잔액이 부족합니다.");
178-
}
178+
}
179179

180180
// 잔액 차감..
181181
long newBalance = current - amount;

src/main/java/com/backend/domain/payment/service/PaymentService.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ public class PaymentService {
3737
private static final long MIN_AMOUNT = 100L; // 최소 100원
3838
private static final long MAX_AMOUNT_PER_TX = 1_000_000L; // 1회 한도(예시)..
3939

40-
@Transactional // 원자성 보장..
40+
// 지갑 충전..
41+
@Transactional
4142
public PaymentResponse charge(Member actor, PaymentRequest req) {
4243

4344
// 입력 검증..
@@ -146,6 +147,7 @@ public PaymentResponse charge(Member actor, PaymentRequest req) {
146147
return toResponse(payment, newBalance, tx.getId());
147148
}
148149

150+
// 내 결제 내역 목록..
149151
@Transactional(readOnly = true)
150152
public MyPaymentsResponse getMyPayments(Member member, int page1Base, int size) {
151153
int page0 = Math.max(0, page1Base - 1);
@@ -165,6 +167,7 @@ public MyPaymentsResponse getMyPayments(Member member, int page1Base, int size)
165167
.build();
166168
}
167169

170+
// 내 결제 단건 상세..
168171
private MyPaymentListItemResponse toListItem(Payment p) {
169172
String provider = p.getProvider();
170173
String methodType = p.getMethodType();
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.backend.domain.bid.controller;
2+
3+
import com.backend.domain.bid.entity.Bid;
4+
import com.backend.domain.bid.repository.BidRepository;
5+
import com.backend.domain.cash.entity.Cash;
6+
import com.backend.domain.cash.repository.CashRepository;
7+
import com.backend.domain.member.entity.Member;
8+
import com.backend.domain.member.repository.MemberRepository;
9+
import com.backend.domain.product.entity.Product;
10+
import com.backend.domain.product.enums.AuctionStatus;
11+
import com.backend.domain.product.enums.DeliveryMethod;
12+
import com.backend.domain.product.enums.ProductCategory;
13+
import jakarta.persistence.EntityManager;
14+
import org.junit.jupiter.api.BeforeEach;
15+
import org.junit.jupiter.api.Test;
16+
import org.springframework.beans.factory.annotation.Autowired;
17+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
18+
import org.springframework.boot.test.context.SpringBootTest;
19+
import org.springframework.security.test.context.support.WithMockUser;
20+
import org.springframework.test.web.servlet.MockMvc;
21+
import org.springframework.transaction.annotation.Transactional;
22+
23+
import java.time.LocalDateTime;
24+
25+
import static org.hamcrest.Matchers.*;
26+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
27+
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
28+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
29+
30+
@SpringBootTest
31+
@AutoConfigureMockMvc(addFilters = false) // JWT 등 보안 필터 비활성화(단순화)
32+
@Transactional
33+
class BidPayControllerTest {
34+
35+
@Autowired MockMvc mvc;
36+
@Autowired EntityManager em;
37+
38+
@Autowired MemberRepository memberRepository;
39+
@Autowired CashRepository cashRepository;
40+
@Autowired BidRepository bidRepository;
41+
42+
Member seller;
43+
Member buyer;
44+
Product product; // 낙찰 상태
45+
Bid bid; // buyer의 최고 입찰(= currentPrice)
46+
47+
@BeforeEach
48+
void setUp() {
49+
// 1) 판매자/구매자 (password는 NOT NULL)
50+
seller = memberRepository.save(
51+
Member.builder().email("[email protected]").password("pw").nickname("seller").build()
52+
);
53+
buyer = memberRepository.save(
54+
Member.builder().email("[email protected]").password("pw").nickname("buyer").build()
55+
);
56+
57+
// 2) 상품: 낙찰 상태 + 최고가 7,000
58+
LocalDateTime start = LocalDateTime.now().minusHours(2);
59+
int duration = 1;
60+
product = new Product(
61+
"테스트 상품",
62+
"설명",
63+
ProductCategory.values()[0],
64+
1_000L,
65+
start,
66+
duration,
67+
DeliveryMethod.values()[0],
68+
"서울",
69+
seller
70+
);
71+
product.setStatus(AuctionStatus.SUCCESSFUL.getDisplayName()); // "낙찰"
72+
product.setCurrentPrice(7_000L);
73+
em.persist(product);
74+
75+
// 3) 지갑: 10,000
76+
cashRepository.save(Cash.builder().member(buyer).balance(10_000L).build());
77+
78+
// 4) 내 최고 입찰(7,000)
79+
bid = bidRepository.save(
80+
Bid.builder().product(product).member(buyer).bidPrice(7_000L).status("bidding").build()
81+
);
82+
}
83+
84+
@Test
85+
@WithMockUser(username = "[email protected]") // 컨트롤러가 이메일/숫자 둘 다 지원
86+
void 낙찰_결제_API_성공_200_RsData형식() throws Exception {
87+
mvc.perform(post("/api/v1/bids/{bidId}/pay", bid.getId()))
88+
.andDo(print())
89+
// GlobalExceptionHandler를 안 타는 정상 플로우 → 200대 resultCode
90+
.andExpect(status().isOk())
91+
.andExpect(jsonPath("$.resultCode", startsWith("200")))
92+
.andExpect(jsonPath("$.msg", notNullValue()))
93+
.andExpect(jsonPath("$.data.amount", is(7_000)))
94+
.andExpect(jsonPath("$.data.balanceAfter", is(3_000)))
95+
.andExpect(jsonPath("$.data.paidAt", notNullValue()))
96+
.andExpect(jsonPath("$.data.cashTransactionId", notNullValue()));
97+
}
98+
99+
@Test
100+
void 인증_없으면_401_바디검증없이상태만() throws Exception {
101+
// 컨트롤러에서 ResponseStatusException(401)을 던지며,
102+
// 이 경우 기본 핸들링으로 바디가 비어있을 수 있으니 상태코드만 확인
103+
mvc.perform(post("/api/v1/bids/{bidId}/pay", bid.getId()))
104+
.andDo(print())
105+
.andExpect(status().isUnauthorized());
106+
}
107+
108+
@Test
109+
@WithMockUser(username = "[email protected]")
110+
void 낙찰_상태가_아니면_400_RsData형식으로_msg존재() throws Exception {
111+
// 실패 유도: 경매중으로 바꿈
112+
product.setStatus(AuctionStatus.BIDDING.getDisplayName()); // "경매 중"
113+
em.flush();
114+
115+
mvc.perform(post("/api/v1/bids/{bidId}/pay", bid.getId()))
116+
.andDo(print())
117+
// ServiceException("400", "...") → GlobalExceptionHandler 가
118+
// status=400, body=RsData(resultCode="400-..." 또는 "400", msg="...") 로 변환
119+
.andExpect(status().isBadRequest())
120+
.andExpect(jsonPath("$.resultCode", startsWith("400")))
121+
.andExpect(jsonPath("$.msg", containsString("낙찰이 확정되지"))); // 서비스 예외 메시지 일부
122+
}
123+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
package com.backend.domain.cash.controller;
2+
3+
import com.backend.domain.cash.constant.CashTxType;
4+
import com.backend.domain.cash.constant.RelatedType;
5+
import com.backend.domain.cash.entity.Cash;
6+
import com.backend.domain.cash.entity.CashTransaction;
7+
import com.backend.domain.cash.repository.CashRepository;
8+
import com.backend.domain.cash.repository.CashTransactionRepository;
9+
import com.backend.domain.member.entity.Member;
10+
import com.backend.domain.member.repository.MemberRepository;
11+
import org.junit.jupiter.api.BeforeEach;
12+
import org.junit.jupiter.api.Test;
13+
import org.springframework.beans.factory.annotation.Autowired;
14+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
15+
import org.springframework.boot.test.context.SpringBootTest;
16+
import org.springframework.security.test.context.support.WithMockUser;
17+
import org.springframework.test.web.servlet.MockMvc;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
import static org.hamcrest.Matchers.*;
21+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
22+
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
23+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
24+
25+
@SpringBootTest
26+
@AutoConfigureMockMvc(addFilters = false) // JWT 등 보안 필터 비활성화
27+
@Transactional
28+
class ApiV1CashControllerTest {
29+
30+
@Autowired MockMvc mvc;
31+
32+
@Autowired MemberRepository memberRepository;
33+
@Autowired CashRepository cashRepository;
34+
@Autowired CashTransactionRepository cashTxRepository;
35+
36+
Member me;
37+
Member other;
38+
Cash myCash;
39+
CashTransaction txDeposit; // id가 더 작음
40+
CashTransaction txWithdraw; // id가 더 큼 (목록에서 먼저 나와야 함)
41+
42+
@BeforeEach
43+
void setUp() {
44+
// 회원(비번은 NOT NULL이라 꼭 채움)
45+
me = memberRepository.save(Member.builder()
46+
.email("[email protected]").password("pw").nickname("me").build());
47+
other = memberRepository.save(Member.builder()
48+
.email("[email protected]").password("pw").nickname("other").build());
49+
50+
// 내 지갑(잔액 10,000)
51+
myCash = cashRepository.save(Cash.builder().member(me).balance(10_000L).build());
52+
53+
// 원장 2건: 입금(10,000) → 출금(3,000) (정상 흐름)
54+
txDeposit = cashTxRepository.save(CashTransaction.builder()
55+
.cash(myCash)
56+
.type(CashTxType.DEPOSIT)
57+
.amount(10_000L)
58+
.balanceAfter(10_000L)
59+
.relatedType(RelatedType.PAYMENT)
60+
.relatedId(101L) // 임의 값
61+
.build());
62+
63+
txWithdraw = cashTxRepository.save(CashTransaction.builder()
64+
.cash(myCash)
65+
.type(CashTxType.WITHDRAW)
66+
.amount(3_000L)
67+
.balanceAfter(7_000L)
68+
.relatedType(RelatedType.PAYMENT)
69+
.relatedId(102L) // 임의 값
70+
.build());
71+
72+
// other 유저의 지갑/원장(권한 차단 테스트용)
73+
Cash otherCash = cashRepository.save(Cash.builder().member(other).balance(5_000L).build());
74+
cashTxRepository.save(CashTransaction.builder()
75+
.cash(otherCash)
76+
.type(CashTxType.DEPOSIT)
77+
.amount(5_000L)
78+
.balanceAfter(5_000L)
79+
.relatedType(RelatedType.PAYMENT)
80+
.relatedId(201L)
81+
.build());
82+
}
83+
84+
// ============ /api/v1/cash ============
85+
@Test
86+
@WithMockUser(username = "[email protected]") // 컨트롤러는 email로 회원 조회
87+
void 내_지갑_조회_200() throws Exception {
88+
mvc.perform(get("/api/v1/cash"))
89+
.andDo(print())
90+
.andExpect(status().isOk())
91+
.andExpect(jsonPath("$.memberId", is(me.getId().intValue())))
92+
.andExpect(jsonPath("$.cashId", is(myCash.getId().intValue())))
93+
.andExpect(jsonPath("$.balance", is(10_000)))
94+
.andExpect(jsonPath("$.createDate", notNullValue()))
95+
.andExpect(jsonPath("$.modifyDate", notNullValue()));
96+
}
97+
98+
@Test
99+
void 내_지갑_조회_인증없으면_401() throws Exception {
100+
// GlobalExceptionHandler가 ResponseStatusException을 RsData로 변환하진 않으므로
101+
// 여기서는 상태코드만 검사
102+
mvc.perform(get("/api/v1/cash"))
103+
.andDo(print())
104+
.andExpect(status().isUnauthorized());
105+
}
106+
107+
// ============ /api/v1/cash/transactions ============
108+
@Test
109+
@WithMockUser(username = "[email protected]")
110+
void 내_원장_목록_200_정렬검증() throws Exception {
111+
mvc.perform(get("/api/v1/cash/transactions")
112+
.param("page", "1")
113+
.param("size", "20"))
114+
.andDo(print())
115+
.andExpect(status().isOk())
116+
.andExpect(jsonPath("$.page", is(1)))
117+
.andExpect(jsonPath("$.size", is(20)))
118+
.andExpect(jsonPath("$.total", is(2)))
119+
.andExpect(jsonPath("$.items", hasSize(2)))
120+
// 최신(id DESC) → 출금(7000) 먼저
121+
.andExpect(jsonPath("$.items[0].type", is("WITHDRAW")))
122+
.andExpect(jsonPath("$.items[0].amount", is(3_000)))
123+
.andExpect(jsonPath("$.items[0].balanceAfter", is(7_000)))
124+
.andExpect(jsonPath("$.items[0].related.type", is("PAYMENT")))
125+
.andExpect(jsonPath("$.items[0].related.id", is(102)))
126+
127+
// 그 다음 입금(10000)
128+
.andExpect(jsonPath("$.items[1].type", is("DEPOSIT")))
129+
.andExpect(jsonPath("$.items[1].amount", is(10_000)))
130+
.andExpect(jsonPath("$.items[1].balanceAfter", is(10_000)))
131+
.andExpect(jsonPath("$.items[1].related.type", is("PAYMENT")))
132+
.andExpect(jsonPath("$.items[1].related.id", is(101)));
133+
}
134+
135+
@Test
136+
void 내_원장_목록_인증없으면_401() throws Exception {
137+
mvc.perform(get("/api/v1/cash/transactions"))
138+
.andDo(print())
139+
.andExpect(status().isUnauthorized());
140+
}
141+
142+
// ============ /api/v1/cash/transactions/{id} ============
143+
@Test
144+
@WithMockUser(username = "[email protected]")
145+
void 내_원장_단건_상세_200_링크존재() throws Exception {
146+
mvc.perform(get("/api/v1/cash/transactions/{id}", txWithdraw.getId()))
147+
.andDo(print())
148+
.andExpect(status().isOk())
149+
.andExpect(jsonPath("$.transactionId", is(txWithdraw.getId().intValue())))
150+
.andExpect(jsonPath("$.cashId", is(myCash.getId().intValue())))
151+
.andExpect(jsonPath("$.type", is("WITHDRAW")))
152+
.andExpect(jsonPath("$.amount", is(3_000)))
153+
.andExpect(jsonPath("$.balanceAfter", is(7_000)))
154+
.andExpect(jsonPath("$.createdAt", notNullValue()))
155+
// Related + Links (PAYMENT면 paymentDetail 링크 생성)
156+
.andExpect(jsonPath("$.related.type", is("PAYMENT")))
157+
.andExpect(jsonPath("$.related.id", is(102)))
158+
.andExpect(jsonPath("$.related.links.paymentDetail", is("/api/v1/payments/me/102")));
159+
}
160+
161+
@Test
162+
@WithMockUser(username = "[email protected]")
163+
void 다른사람_원장_단건_조회시_404() throws Exception {
164+
// other 유저의 트랜잭션 id를 찾아서 접근 시도
165+
Long othersTxId = cashTxRepository.findAll().stream()
166+
.filter(tx -> tx.getCash().getMember().getId().equals(other.getId()))
167+
.map(CashTransaction::getId)
168+
.findFirst()
169+
.orElseThrow();
170+
171+
// CashService는 ResponseStatusException(404)을 던짐 → 전역 핸들러가 RsData로 바꾸지 않으므로 상태만 체크
172+
mvc.perform(get("/api/v1/cash/transactions/{id}", othersTxId))
173+
.andDo(print())
174+
.andExpect(status().isNotFound());
175+
}
176+
}

0 commit comments

Comments
 (0)