1+ package com .back .api .ticket .scheduler ;
2+
3+ import static org .assertj .core .api .Assertions .*;
4+
5+ import java .lang .reflect .Field ;
6+ import java .time .LocalDateTime ;
7+
8+ import org .junit .jupiter .api .BeforeEach ;
9+ import org .junit .jupiter .api .DisplayName ;
10+ import org .junit .jupiter .api .Test ;
11+ import org .springframework .beans .factory .annotation .Autowired ;
12+ import org .springframework .boot .test .context .SpringBootTest ;
13+ import org .springframework .test .context .ActiveProfiles ;
14+ import org .springframework .transaction .annotation .Transactional ;
15+
16+ import com .back .domain .event .entity .Event ;
17+ import com .back .domain .seat .entity .Seat ;
18+ import com .back .domain .seat .entity .SeatGrade ;
19+ import com .back .domain .seat .entity .SeatStatus ;
20+ import com .back .domain .seat .repository .SeatRepository ;
21+ import com .back .domain .ticket .entity .Ticket ;
22+ import com .back .domain .ticket .entity .TicketStatus ;
23+ import com .back .domain .ticket .repository .TicketRepository ;
24+ import com .back .domain .user .entity .User ;
25+ import com .back .domain .user .entity .UserRole ;
26+ import com .back .support .helper .EventHelper ;
27+ import com .back .support .helper .SeatHelper ;
28+ import com .back .support .helper .TicketHelper ;
29+ import com .back .support .helper .UserHelper ;
30+
31+ @ SpringBootTest
32+ @ ActiveProfiles ("test" )
33+ @ Transactional
34+ @ DisplayName ("DraftTicketExpirationScheduler 통합 테스트" )
35+ class DraftTicketExpirationSchedulerTest {
36+
37+ @ Autowired
38+ private DraftTicketExpirationScheduler scheduler ;
39+
40+ @ Autowired
41+ private TicketRepository ticketRepository ;
42+
43+ @ Autowired
44+ private SeatRepository seatRepository ;
45+
46+ @ Autowired
47+ private UserHelper userHelper ;
48+
49+ @ Autowired
50+ private EventHelper eventHelper ;
51+
52+ @ Autowired
53+ private SeatHelper seatHelper ;
54+
55+ @ Autowired
56+ private TicketHelper ticketHelper ;
57+
58+ private User user ;
59+ private Event event ;
60+ private Seat seat ;
61+
62+ @ BeforeEach
63+ void setUp () {
64+ user = userHelper .createUser (UserRole .NORMAL ).user ();
65+ event = eventHelper .createEvent ();
66+ seat = seatHelper .createSeat (event , "A1" , SeatGrade .VIP );
67+ }
68+
69+ @ Test
70+ @ DisplayName ("좌석이 할당된 만료된 Draft 티켓을 정상적으로 처리한다" )
71+ void expireDraftTicket_withSeat_success () throws Exception {
72+ // given: 15분 이상 경과한 Draft 티켓 생성 (좌석 할당됨)
73+ Ticket ticket = ticketHelper .createDraftTicket (user , seat , event );
74+ setCreatedAt (ticket , LocalDateTime .now ().minusMinutes (20 ));
75+
76+ // 좌석을 RESERVED 상태로 변경
77+ seat .markAsReserved ();
78+ seatRepository .save (seat );
79+
80+ // when: 스케줄러 실행
81+ scheduler .expireDraftTickets ();
82+
83+ // then: 티켓이 FAILED 상태로 변경됨
84+ Ticket expiredTicket = ticketRepository .findById (ticket .getId ()).orElseThrow ();
85+ assertThat (expiredTicket .getTicketStatus ()).isEqualTo (TicketStatus .FAILED );
86+
87+ // then: 좌석이 AVAILABLE 상태로 변경됨
88+ Seat releasedSeat = seatRepository .findById (seat .getId ()).orElseThrow ();
89+ assertThat (releasedSeat .getSeatStatus ()).isEqualTo (SeatStatus .AVAILABLE );
90+ }
91+
92+ @ Test
93+ @ DisplayName ("좌석이 없는 만료된 Draft 티켓 처리 시 NPE 발생으로 FAILED 변경 실패 (현재 버그)" )
94+ void expireDraftTicket_withoutSeat_npeOccurs () throws Exception {
95+ // given: 15분 이상 경과한 Draft 티켓 생성 (좌석 없음)
96+ Ticket ticket = Ticket .builder ()
97+ .owner (user )
98+ .event (event )
99+ .seat (null ) // 좌석 없음
100+ .ticketStatus (TicketStatus .DRAFT )
101+ .build ();
102+ ticketRepository .save (ticket );
103+ setCreatedAt (ticket , LocalDateTime .now ().minusMinutes (20 ));
104+
105+ // when: 스케줄러 실행 (NPE가 발생하지만 try-catch로 잡힘)
106+ scheduler .expireDraftTickets ();
107+
108+ // then: NPE 발생으로 티켓 상태가 DRAFT 그대로 유지됨 (버그!)
109+ Ticket unchangedTicket = ticketRepository .findById (ticket .getId ()).orElseThrow ();
110+ assertThat (unchangedTicket .getTicketStatus ())
111+ .as ("NPE 발생으로 인해 DRAFT 상태 그대로 유지됨 (버그)" )
112+ .isEqualTo (TicketStatus .FAILED ); // 버그: FAILED가 되어야 하는데 DRAFT 그대로
113+ }
114+
115+ @ Test
116+ @ DisplayName ("만료되지 않은 Draft 티켓은 처리하지 않는다" )
117+ void expireDraftTicket_notExpired_ignored () {
118+ // given: 최근 생성된 Draft 티켓 (만료되지 않음)
119+ Ticket ticket = ticketHelper .createDraftTicket (user , seat , event );
120+
121+ // when: 스케줄러 실행
122+ scheduler .expireDraftTickets ();
123+
124+ // then: 티켓 상태가 변경되지 않음
125+ Ticket notExpiredTicket = ticketRepository .findById (ticket .getId ()).orElseThrow ();
126+ assertThat (notExpiredTicket .getTicketStatus ()).isEqualTo (TicketStatus .DRAFT );
127+ }
128+
129+ @ Test
130+ @ DisplayName ("PAID 상태의 티켓은 처리하지 않는다" )
131+ void expireDraftTicket_paidTicket_ignored () throws Exception {
132+ // given: 만료 시간이 지난 PAID 티켓
133+ Ticket ticket = ticketHelper .createPaidTicket (user , seat , event );
134+ setCreatedAt (ticket , LocalDateTime .now ().minusMinutes (20 ));
135+
136+ // when: 스케줄러 실행
137+ scheduler .expireDraftTickets ();
138+
139+ // then: 티켓 상태가 변경되지 않음 (PAID 유지)
140+ Ticket unchangedTicket = ticketRepository .findById (ticket .getId ()).orElseThrow ();
141+ assertThat (unchangedTicket .getTicketStatus ()).isEqualTo (TicketStatus .PAID );
142+ }
143+
144+ @ Test
145+ @ DisplayName ("여러 개의 만료된 Draft 티켓을 배치로 처리한다" )
146+ void expireDraftTicket_multipleTickets_success () throws Exception {
147+ // given: 3개의 만료된 Draft 티켓 생성
148+ Seat seat2 = seatHelper .createSeat (event , "A2" , SeatGrade .VIP );
149+ Seat seat3 = seatHelper .createSeat (event , "A3" , SeatGrade .VIP );
150+
151+ Ticket ticket1 = ticketHelper .createDraftTicket (user , seat , event );
152+ Ticket ticket2 = ticketHelper .createDraftTicket (user , seat2 , event );
153+ Ticket ticket3 = ticketHelper .createDraftTicket (user , seat3 , event );
154+
155+ setCreatedAt (ticket1 , LocalDateTime .now ().minusMinutes (20 ));
156+ setCreatedAt (ticket2 , LocalDateTime .now ().minusMinutes (20 ));
157+ setCreatedAt (ticket3 , LocalDateTime .now ().minusMinutes (20 ));
158+
159+ // 좌석들을 RESERVED 상태로 변경
160+ seat .markAsReserved ();
161+ seat2 .markAsReserved ();
162+ seat3 .markAsReserved ();
163+ seatRepository .save (seat );
164+ seatRepository .save (seat2 );
165+ seatRepository .save (seat3 );
166+
167+ // when: 스케줄러 실행
168+ scheduler .expireDraftTickets ();
169+
170+ // then: 모든 티켓이 FAILED 상태로 변경됨
171+ assertThat (ticketRepository .findById (ticket1 .getId ()).orElseThrow ().getTicketStatus ())
172+ .isEqualTo (TicketStatus .FAILED );
173+ assertThat (ticketRepository .findById (ticket2 .getId ()).orElseThrow ().getTicketStatus ())
174+ .isEqualTo (TicketStatus .FAILED );
175+ assertThat (ticketRepository .findById (ticket3 .getId ()).orElseThrow ().getTicketStatus ())
176+ .isEqualTo (TicketStatus .FAILED );
177+
178+ // then: 모든 좌석이 AVAILABLE 상태로 변경됨
179+ assertThat (seatRepository .findById (seat .getId ()).orElseThrow ().getSeatStatus ())
180+ .isEqualTo (SeatStatus .AVAILABLE );
181+ assertThat (seatRepository .findById (seat2 .getId ()).orElseThrow ().getSeatStatus ())
182+ .isEqualTo (SeatStatus .AVAILABLE );
183+ assertThat (seatRepository .findById (seat3 .getId ()).orElseThrow ().getSeatStatus ())
184+ .isEqualTo (SeatStatus .AVAILABLE );
185+ }
186+
187+ @ Test
188+ @ DisplayName ("좌석이 없는 티켓과 있는 티켓이 섞여있어도 정상 처리한다" )
189+ void expireDraftTicket_mixedTickets_success () throws Exception {
190+ // given: 좌석 있는 티켓과 없는 티켓 혼합
191+ Ticket ticketWithSeat = ticketHelper .createDraftTicket (user , seat , event );
192+ Ticket ticketWithoutSeat = Ticket .builder ()
193+ .owner (user )
194+ .event (event )
195+ .seat (null )
196+ .ticketStatus (TicketStatus .DRAFT )
197+ .build ();
198+ ticketRepository .save (ticketWithoutSeat );
199+
200+ setCreatedAt (ticketWithSeat , LocalDateTime .now ().minusMinutes (20 ));
201+ setCreatedAt (ticketWithoutSeat , LocalDateTime .now ().minusMinutes (20 ));
202+
203+ seat .markAsReserved ();
204+ seatRepository .save (seat );
205+
206+ // when: 스케줄러 실행
207+ scheduler .expireDraftTickets ();
208+
209+ // then: 두 티켓 모두 FAILED 상태로 변경됨
210+ assertThat (ticketRepository .findById (ticketWithSeat .getId ()).orElseThrow ().getTicketStatus ())
211+ .isEqualTo (TicketStatus .FAILED );
212+ assertThat (ticketRepository .findById (ticketWithoutSeat .getId ()).orElseThrow ().getTicketStatus ())
213+ .isEqualTo (TicketStatus .FAILED );
214+
215+ // then: 좌석 있던 티켓의 좌석만 AVAILABLE로 변경됨
216+ assertThat (seatRepository .findById (seat .getId ()).orElseThrow ().getSeatStatus ())
217+ .isEqualTo (SeatStatus .AVAILABLE );
218+ }
219+
220+ // ========== Helper Methods ==========
221+
222+ /**
223+ * Reflection을 사용하여 createAt 필드를 수정
224+ */
225+ private void setCreatedAt (Ticket ticket , LocalDateTime time ) throws Exception {
226+ Field createAtField = ticket .getClass ().getSuperclass ().getDeclaredField ("createAt" );
227+ createAtField .setAccessible (true );
228+ createAtField .set (ticket , time );
229+ ticketRepository .saveAndFlush (ticket );
230+ }
231+ }
0 commit comments