Skip to content

Commit 24b5fdd

Browse files
test: QR 도메인 테스트 코드
1 parent 0c14b55 commit 24b5fdd

3 files changed

Lines changed: 923 additions & 0 deletions

File tree

backend/src/main/java/com/back/domain/ticket/entity/Ticket.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,15 @@ public void clearSeat() {
156156
public boolean hasSeat() {
157157
return this.seat != null;
158158
}
159+
160+
public void changeStatus(TicketStatus status) {
161+
this.ticketStatus = status;
162+
163+
if (status == TicketStatus.ISSUED && this.issuedAt == null) {
164+
this.issuedAt = LocalDateTime.now();
165+
}
166+
if (status == TicketStatus.USED && this.usedAt == null) {
167+
this.usedAt = LocalDateTime.now();
168+
}
169+
}
159170
}
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
1+
package com.back.api.ticket.controller;
2+
3+
import static org.assertj.core.api.Assertions.*;
4+
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
5+
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*;
6+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
7+
8+
import java.time.LocalDateTime;
9+
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.DisplayName;
12+
import org.junit.jupiter.api.Nested;
13+
import org.junit.jupiter.api.Test;
14+
import org.springframework.beans.factory.annotation.Autowired;
15+
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
16+
import org.springframework.boot.test.context.SpringBootTest;
17+
import org.springframework.context.annotation.Import;
18+
import org.springframework.data.redis.core.StringRedisTemplate;
19+
import org.springframework.http.MediaType;
20+
import org.springframework.test.context.ActiveProfiles;
21+
import org.springframework.test.web.servlet.MockMvc;
22+
import org.springframework.test.web.servlet.MvcResult;
23+
import org.springframework.transaction.annotation.Transactional;
24+
25+
import com.back.config.TestRedisConfig;
26+
import com.back.domain.event.entity.Event;
27+
import com.back.domain.event.entity.EventCategory;
28+
import com.back.domain.event.entity.EventStatus;
29+
import com.back.domain.event.repository.EventRepository;
30+
import com.back.domain.seat.entity.Seat;
31+
import com.back.domain.seat.entity.SeatGrade;
32+
import com.back.domain.store.entity.Store;
33+
import com.back.domain.ticket.entity.Ticket;
34+
import com.back.domain.ticket.entity.TicketStatus;
35+
import com.back.domain.ticket.repository.TicketRepository;
36+
import com.back.domain.user.entity.User;
37+
import com.back.domain.user.entity.UserRole;
38+
import com.back.support.data.TestUser;
39+
import com.back.support.helper.SeatHelper;
40+
import com.back.support.helper.StoreHelper;
41+
import com.back.support.helper.TestAuthHelper;
42+
import com.back.support.helper.TicketHelper;
43+
import com.back.support.helper.UserHelper;
44+
import com.fasterxml.jackson.databind.JsonNode;
45+
import com.fasterxml.jackson.databind.ObjectMapper;
46+
47+
@SpringBootTest
48+
@AutoConfigureMockMvc(addFilters = false)
49+
@ActiveProfiles("test")
50+
@Transactional
51+
@Import(TestRedisConfig.class)
52+
@DisplayName("QrController 통합 테스트")
53+
public class QrControllerTest {
54+
55+
@Autowired
56+
private MockMvc mockMvc;
57+
58+
@Autowired
59+
private TestAuthHelper testAuthHelper;
60+
61+
@Autowired
62+
private UserHelper userHelper;
63+
64+
@Autowired
65+
private SeatHelper seatHelper;
66+
67+
@Autowired
68+
private TicketHelper ticketHelper;
69+
70+
@Autowired
71+
private StoreHelper storeHelper;
72+
73+
@Autowired
74+
private EventRepository eventRepository;
75+
76+
@Autowired
77+
private TicketRepository ticketRepository;
78+
79+
@Autowired
80+
private StringRedisTemplate redisTemplate;
81+
82+
@Autowired
83+
private ObjectMapper objectMapper;
84+
85+
private Store store;
86+
private User testUser;
87+
private Event testEvent;
88+
private Seat testSeat;
89+
private Ticket testTicket;
90+
91+
@BeforeEach
92+
void setUp() {
93+
store = storeHelper.createStore();
94+
TestUser user = userHelper.createUser(UserRole.NORMAL, null);
95+
testUser = user.user();
96+
testAuthHelper.authenticate(testUser);
97+
98+
LocalDateTime now = LocalDateTime.now();
99+
testEvent = Event.builder()
100+
.title("QR 테스트 이벤트")
101+
.category(EventCategory.CONCERT)
102+
.description("QR 테스트용 이벤트입니다")
103+
.place("테스트 장소")
104+
.imageUrl("https://example.com/image.jpg")
105+
.minPrice(10000)
106+
.maxPrice(50000)
107+
.preOpenAt(now.minusDays(10))
108+
.preCloseAt(now.minusDays(8))
109+
.ticketOpenAt(now.minusDays(5))
110+
.ticketCloseAt(now.plusDays(1))
111+
.eventDate(now.minusHours(1)) // 이벤트가 이미 시작됨
112+
.maxTicketAmount(1000)
113+
.status(EventStatus.OPEN)
114+
.store(store)
115+
.build();
116+
eventRepository.save(testEvent);
117+
118+
// 좌석 생성
119+
testSeat = seatHelper.createSeat(testEvent, "A1", SeatGrade.VIP);
120+
121+
// ISSUED 티켓 생성
122+
testTicket = ticketHelper.createIssuedTicket(testUser, testSeat, testEvent);
123+
}
124+
125+
126+
@Nested
127+
@DisplayName("QR 토큰 발급 API (POST /api/v1/tickets/{ticketId}/qr-token)")
128+
class GenerateQrToken {
129+
130+
@Test
131+
@DisplayName("ISSUED 상태 티켓으로 QR 토큰 발급 성공")
132+
void generateQrToken_Success() throws Exception {
133+
// when & then
134+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", testTicket.getId())
135+
.contentType(MediaType.APPLICATION_JSON))
136+
.andDo(print())
137+
.andExpect(status().isOk())
138+
.andExpect(jsonPath("$.message").value("QR 토큰 발급 성공"))
139+
.andExpect(jsonPath("$.data.qrToken").isNotEmpty())
140+
.andExpect(jsonPath("$.data.expirationSecond").value(60))
141+
.andExpect(jsonPath("$.data.refreshIntervalSecond").value(30))
142+
.andExpect(jsonPath("$.data.qrUrl").isNotEmpty());
143+
}
144+
145+
@Test
146+
@DisplayName("이벤트 시작 전에는 QR 토큰 발급 불가")
147+
void generateQrToken_Fail_EventNotStarted() throws Exception {
148+
// given
149+
150+
LocalDateTime now = LocalDateTime.now();
151+
Event futureEvent =Event.builder()
152+
.title("QR 테스트 이벤트")
153+
.category(EventCategory.CONCERT)
154+
.description("QR 테스트용 이벤트입니다")
155+
.place("테스트 장소")
156+
.imageUrl("https://example.com/image.jpg")
157+
.minPrice(10000)
158+
.maxPrice(50000)
159+
.preOpenAt(now.minusDays(10))
160+
.preCloseAt(now.minusDays(8))
161+
.ticketOpenAt(now.minusDays(5))
162+
.ticketCloseAt(now.plusDays(1))
163+
.eventDate(now.plusDays(10))
164+
.maxTicketAmount(1000)
165+
.status(EventStatus.OPEN)
166+
.store(store)
167+
.build();
168+
169+
eventRepository.save(futureEvent);
170+
171+
Seat futureSeat = seatHelper.createSeat(futureEvent, "B1");
172+
Ticket futureTicket = ticketHelper.createIssuedTicket(testUser, futureSeat, futureEvent);
173+
174+
// when & then
175+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", futureTicket.getId())
176+
.contentType(MediaType.APPLICATION_JSON))
177+
.andDo(print())
178+
.andExpect(status().isBadRequest());
179+
}
180+
181+
@Test
182+
@DisplayName("ISSUED 상태가 아닌 티켓은 QR 발급 불가 - USED")
183+
void generateQrToken_Fail_UsedTicket() throws Exception {
184+
// given
185+
testTicket.markAsUsed();
186+
ticketRepository.saveAndFlush(testTicket);
187+
188+
// when & then
189+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", testTicket.getId())
190+
.contentType(MediaType.APPLICATION_JSON))
191+
.andDo(print())
192+
.andExpect(status().isBadRequest());
193+
}
194+
195+
@Test
196+
@DisplayName("ISSUED 상태가 아닌 티켓은 QR 발급 불가 - DRAFT")
197+
void generateQrToken_Fail_DraftTicket() throws Exception {
198+
// given
199+
Seat seat2 = seatHelper.createSeat(testEvent, "B1");
200+
Ticket draftTicket = ticketHelper.createDraftTicket(testUser, seat2, testEvent);
201+
202+
// when & then
203+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", draftTicket.getId())
204+
.contentType(MediaType.APPLICATION_JSON))
205+
.andDo(print())
206+
.andExpect(status().isBadRequest());
207+
}
208+
209+
@Test
210+
@DisplayName("다른 사용자의 티켓으로는 QR 발급 불가")
211+
void generateQrToken_Fail_UnauthorizedAccess() throws Exception {
212+
// given
213+
TestUser otherUser = userHelper.createUser(UserRole.NORMAL, null);
214+
testAuthHelper.authenticate(otherUser.user());
215+
216+
// when & then
217+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", testTicket.getId())
218+
.contentType(MediaType.APPLICATION_JSON))
219+
.andDo(print())
220+
.andExpect(status().isBadRequest());
221+
}
222+
223+
@Test
224+
@DisplayName("존재하지 않는 티켓으로 QR 발급 시도 시 404")
225+
void generateQrToken_Fail_TicketNotFound() throws Exception {
226+
// when & then
227+
mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", 999L)
228+
.contentType(MediaType.APPLICATION_JSON))
229+
.andDo(print())
230+
.andExpect(status().isBadRequest());
231+
}
232+
}
233+
234+
@Nested
235+
@DisplayName("QR 코드 검증 API (POST /api/v1/tickets/entry/verify)")
236+
class ValidateQrCode {
237+
238+
@Test
239+
@DisplayName("유효한 QR 토큰으로 입장 검증 성공")
240+
void validateQrCode_Success() throws Exception {
241+
// given
242+
String qrToken = generateValidQrToken();
243+
244+
// when & then
245+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
246+
.param("token", qrToken)
247+
.contentType(MediaType.APPLICATION_JSON))
248+
.andDo(print())
249+
.andExpect(status().isOk())
250+
.andExpect(jsonPath("$.message").value("QR 코드 검증 & 사용 처리 성공"))
251+
.andExpect(jsonPath("$.data.isValid").value(true))
252+
.andExpect(jsonPath("$.data.message").value("QR 코드가 유효합니다."))
253+
.andExpect(jsonPath("$.data.ticketId").value(testTicket.getId()))
254+
.andExpect(jsonPath("$.data.eventId").value(testEvent.getId()))
255+
.andExpect(jsonPath("$.data.eventTitle").value(testEvent.getTitle()))
256+
.andExpect(jsonPath("$.data.seatCode").value("A1"))
257+
.andExpect(jsonPath("$.data.ownerNickname").value(testUser.getNickname()))
258+
.andExpect(jsonPath("$.data.eventDate").isNotEmpty())
259+
.andExpect(jsonPath("$.data.qrIssuedAt").isNotEmpty());
260+
261+
// Redis 입장 기록 확인
262+
String redisKey = "entry:ticket:" + testTicket.getId();
263+
String entryRecord = redisTemplate.opsForValue().get(redisKey);
264+
assertThat(entryRecord).isNotNull();
265+
266+
// 티켓 상태 변경 확인
267+
Ticket updatedTicket = ticketRepository.findById(testTicket.getId()).orElseThrow();
268+
assertThat(updatedTicket.getTicketStatus()).isEqualTo(TicketStatus.USED);
269+
}
270+
271+
@Test
272+
@DisplayName("좌석이 없는 티켓도 입장 처리 가능")
273+
void validateQrCode_Success_NoSeat() throws Exception {
274+
// given
275+
Ticket noSeatTicket = ticketRepository.save(
276+
Ticket.builder()
277+
.owner(testUser)
278+
.seat(null) // 좌석 없음
279+
.event(testEvent)
280+
.ticketStatus(TicketStatus.ISSUED)
281+
.build()
282+
);
283+
284+
String qrToken = generateValidQrTokenForTicket(noSeatTicket.getId());
285+
286+
// when & then
287+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
288+
.param("token", qrToken)
289+
.contentType(MediaType.APPLICATION_JSON))
290+
.andDo(print())
291+
.andExpect(status().isOk())
292+
.andExpect(jsonPath("$.data.isValid").value(true))
293+
.andExpect(jsonPath("$.data.seatCode").isEmpty());
294+
}
295+
296+
@Test
297+
@DisplayName("이미 입장 처리된 티켓은 재입장 불가")
298+
void validateQrCode_Fail_AlreadyEntered() throws Exception {
299+
// given
300+
String qrToken = generateValidQrToken();
301+
302+
// 첫 번째 입장 처리
303+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
304+
.param("token", qrToken)
305+
.contentType(MediaType.APPLICATION_JSON))
306+
.andExpect(status().isOk())
307+
.andExpect(jsonPath("$.data.isValid").value(true));
308+
309+
testTicket.changeStatus(TicketStatus.ISSUED);
310+
ticketRepository.saveAndFlush(testTicket);
311+
312+
// 새로운 QR 토큰 발급
313+
String newQrToken = generateValidQrToken();
314+
315+
// when & then - 두 번째 입장 시도
316+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
317+
.param("token", newQrToken)
318+
.contentType(MediaType.APPLICATION_JSON))
319+
.andDo(print())
320+
.andExpect(status().isOk())
321+
.andExpect(jsonPath("$.data.isValid").value(false))
322+
.andExpect(jsonPath("$.data.message").value("이미 입장 처리된 티켓입니다."));
323+
}
324+
325+
@Test
326+
@DisplayName("유효하지 않은 QR 토큰은 검증 실패")
327+
void validateQrCode_Fail_InvalidToken() throws Exception {
328+
// given
329+
String invalidToken = "invalid.token.here";
330+
331+
// when & then
332+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
333+
.param("token", invalidToken)
334+
.contentType(MediaType.APPLICATION_JSON))
335+
.andDo(print())
336+
.andExpect(status().isBadRequest());
337+
}
338+
339+
@Test
340+
@DisplayName("USED 상태 티켓의 QR은 검증 실패")
341+
void validateQrCode_Fail_UsedTicket() throws Exception {
342+
// given
343+
String qrToken = generateValidQrToken();
344+
345+
// 티켓 상태를 USED로 변경
346+
testTicket.markAsUsed();
347+
ticketRepository.saveAndFlush(testTicket);
348+
349+
// when & then
350+
mockMvc.perform(post("/api/v1/tickets/entry/verify")
351+
.param("token", qrToken)
352+
.contentType(MediaType.APPLICATION_JSON))
353+
.andDo(print())
354+
.andExpect(status().isOk())
355+
.andExpect(jsonPath("$.data.isValid").value(false))
356+
.andExpect(jsonPath("$.data.message").value("유효하지 않은 티켓 상태입니다."));
357+
}
358+
359+
private String generateValidQrToken() throws Exception {
360+
return generateValidQrTokenForTicket(testTicket.getId());
361+
}
362+
363+
private String generateValidQrTokenForTicket(Long ticketId) throws Exception {
364+
MvcResult result = mockMvc.perform(post("/api/v1/tickets/{ticketId}/qr-token", ticketId)
365+
.contentType(MediaType.APPLICATION_JSON))
366+
.andReturn();
367+
368+
String responseBody = result.getResponse().getContentAsString();
369+
JsonNode jsonNode = objectMapper.readTree(responseBody);
370+
return jsonNode.get("data").get("qrToken").asText();
371+
}
372+
}
373+
374+
}

0 commit comments

Comments
 (0)