diff --git a/festival-app/backend/README.md b/festival-app/backend/README.md index e347417..ba71d55 100644 --- a/festival-app/backend/README.md +++ b/festival-app/backend/README.md @@ -286,7 +286,8 @@ export JWT_SECRET="replace-with-at-least-32-byte-secret-key" mvn spring-boot:run ``` -JWT 만료 시간은 필요하면 `JWT_EXPIRATION_MILLIS` 환경변수로 조정할 수 있습니다. +`JWT_SECRET`을 생략하면 로컬 개발용 기본값을 사용합니다. JWT 만료 시간은 필요하면 +`JWT_EXPIRATION_MILLIS` 환경변수로 조정할 수 있습니다. 기본 서버 주소: diff --git a/festival-app/backend/src/main/java/com/festivalapp/api/AdminAuthController.java b/festival-app/backend/src/main/java/com/festivalapp/api/AdminAuthController.java new file mode 100644 index 0000000..92cdc9a --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/api/AdminAuthController.java @@ -0,0 +1,25 @@ +package com.festivalapp.api; + +import com.festivalapp.dto.auth.AuthResponse; +import com.festivalapp.dto.auth.SignupRequest; +import com.festivalapp.service.auth.AuthService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/auth") +@RequiredArgsConstructor +public class AdminAuthController { + + private final AuthService authService; + + @PostMapping("/signup") + ResponseEntity signup(@Valid @RequestBody SignupRequest request) { + return ResponseEntity.ok(authService.signupAdmin(request)); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/api/BoothReservationController.java b/festival-app/backend/src/main/java/com/festivalapp/api/BoothReservationController.java index 618a0d2..07fa7fb 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/api/BoothReservationController.java +++ b/festival-app/backend/src/main/java/com/festivalapp/api/BoothReservationController.java @@ -2,17 +2,20 @@ import com.festivalapp.dto.BoothReservationCreateRequest; import com.festivalapp.dto.BoothReservationResponse; +import com.festivalapp.security.AuthenticatedUser; import com.festivalapp.service.BoothReservationService; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/booth-reservations") @@ -23,7 +26,9 @@ public class BoothReservationController { @PostMapping ResponseEntity createReservation( + Authentication authentication, @RequestBody BoothReservationCreateRequest request) { + validateApplicant(authentication, request.applicantId()); return ResponseEntity .status(HttpStatus.CREATED) .body(boothReservationService.createReservation(request)); @@ -31,7 +36,9 @@ ResponseEntity createReservation( @GetMapping("/applicants/{applicantId}") ResponseEntity> getReservationsByApplicant( + Authentication authentication, @PathVariable String applicantId) { + validateApplicant(authentication, applicantId); return ResponseEntity.ok(boothReservationService.getReservationsByApplicant(applicantId)); } @@ -40,4 +47,12 @@ ResponseEntity getReservation( @PathVariable String reservationId) { return ResponseEntity.ok(boothReservationService.getReservation(reservationId)); } + + private void validateApplicant(Authentication authentication, String applicantId) { + if (authentication == null + || !(authentication.getPrincipal() instanceof AuthenticatedUser user) + || !user.id().equals(applicantId)) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인의 예약 정보만 처리할 수 있습니다."); + } + } } diff --git a/festival-app/backend/src/main/java/com/festivalapp/config/CorsConfig.java b/festival-app/backend/src/main/java/com/festivalapp/config/CorsConfig.java index e7dfdc0..1c18223 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/config/CorsConfig.java +++ b/festival-app/backend/src/main/java/com/festivalapp/config/CorsConfig.java @@ -6,15 +6,15 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; @Configuration @EnableConfigurationProperties(CorsConfig.CorsProperties.class) public class CorsConfig { @Bean - CorsFilter corsFilter(CorsProperties properties) { + CorsConfigurationSource corsConfigurationSource(CorsProperties properties) { var configuration = new CorsConfiguration(); configuration.setAllowedOrigins(properties.allowedOrigins()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); @@ -23,7 +23,7 @@ CorsFilter corsFilter(CorsProperties properties) { var source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", configuration); - return new CorsFilter(source); + return source; } @ConfigurationProperties(prefix = "app.cors") diff --git a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java index 41d15d0..58e15f3 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java +++ b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java @@ -15,6 +15,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfigurationSource; @Configuration @EnableWebSecurity @@ -23,10 +24,12 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CorsConfigurationSource corsConfigurationSource; @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http.csrf(AbstractHttpConfigurer::disable) + return http.cors(cors -> cors.configurationSource(corsConfigurationSource)) + .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) .sessionManagement( @@ -44,6 +47,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/api/auth/signup", "/api/auth/login", "/api/health") .permitAll() + .requestMatchers("/api/admin/auth/signup") + .permitAll() .requestMatchers( HttpMethod.GET, "/api/booths", @@ -53,6 +58,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/map-locations", "/api/map-locations/**") .permitAll() + .requestMatchers(HttpMethod.GET, "/api/booth-reservations/*") + .permitAll() .requestMatchers("/api/admin/**") .hasRole("ADMIN") .anyRequest() diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservation.java b/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservation.java index 8650c02..32fde1c 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservation.java +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservation.java @@ -40,12 +40,23 @@ public void approve(LocalDateTime now) { updatedAt = now; } + public boolean canApprove() { + return status == BoothReservationStatus.PENDING_APPROVAL + || status == BoothReservationStatus.QR_FAILED; + } + public void reserve(String issuedQrCode, LocalDateTime now) { status = BoothReservationStatus.RESERVED; qrCode = issuedQrCode; updatedAt = now; } + public void compensateApprovalAfterQrFailure(LocalDateTime now) { + status = BoothReservationStatus.QR_FAILED; + qrCode = null; + updatedAt = now; + } + public String id() { return id; } diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservationStatus.java b/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservationStatus.java index 303af24..e45a7da 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservationStatus.java +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/booth/reservation/BoothReservationStatus.java @@ -9,6 +9,7 @@ public enum BoothReservationStatus { PENDING_APPROVAL("관리자 승인 대기"), APPROVED("관리자 승인 완료"), RESERVED("QR 발급 완료"), + QR_FAILED("QR 발급 실패"), CHECKED_IN("현장 체크인 완료"), COMPLETED("예약 이용 완료"), CANCELLED("예약 취소"); diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationApprovalRequest.java b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationApprovalRequest.java index 762c143..573e315 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationApprovalRequest.java +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationApprovalRequest.java @@ -2,4 +2,14 @@ public record BoothReservationApprovalRequest( String approverId, - String approverName) {} + String approverName, + Boolean simulateQrFailure) { + + public BoothReservationApprovalRequest(String approverId, String approverName) { + this(approverId, approverName, false); + } + + public boolean shouldSimulateQrFailure() { + return Boolean.TRUE.equals(simulateQrFailure); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationCompensationLogResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationCompensationLogResponse.java new file mode 100644 index 0000000..de148b8 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationCompensationLogResponse.java @@ -0,0 +1,10 @@ +package com.festivalapp.dto; + +import java.time.LocalDateTime; + +public record BoothReservationCompensationLogResponse( + String step, + String reason, + String fromStatus, + String toStatus, + LocalDateTime createdAt) {} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationResponse.java index 01a5bf9..becf9b8 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationResponse.java +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/BoothReservationResponse.java @@ -14,6 +14,7 @@ public record BoothReservationResponse( String statusDescription, String qrCode, List sagaLogs, + List compensationLogs, LocalDateTime createdAt, LocalDateTime updatedAt) { @@ -24,6 +25,13 @@ public static BoothReservationResponse from(BoothReservation reservation) { public static BoothReservationResponse from( BoothReservation reservation, List sagaLogs) { + return from(reservation, sagaLogs, List.of()); + } + + public static BoothReservationResponse from( + BoothReservation reservation, + List sagaLogs, + List compensationLogs) { return new BoothReservationResponse( reservation.id(), reservation.boothId(), @@ -34,6 +42,7 @@ public static BoothReservationResponse from( reservation.status().getDescription(), reservation.qrCode(), sagaLogs, + compensationLogs, reservation.createdAt(), reservation.updatedAt()); } diff --git a/festival-app/backend/src/main/java/com/festivalapp/security/JwtTokenProvider.java b/festival-app/backend/src/main/java/com/festivalapp/security/JwtTokenProvider.java index c6fa33e..e2f4efd 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/security/JwtTokenProvider.java +++ b/festival-app/backend/src/main/java/com/festivalapp/security/JwtTokenProvider.java @@ -6,6 +6,8 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Date; import javax.crypto.SecretKey; import org.springframework.stereotype.Component; @@ -18,7 +20,7 @@ public class JwtTokenProvider { public JwtTokenProvider(JwtProperties jwtProperties) { this.jwtProperties = jwtProperties; - this.secretKey = Keys.hmacShaKeyFor(jwtProperties.secret().getBytes(StandardCharsets.UTF_8)); + this.secretKey = Keys.hmacShaKeyFor(toSigningKey(jwtProperties.secret())); } public String createToken(User user) { @@ -56,4 +58,18 @@ private UserRole parseRole(String role) { return UserRole.valueOf(role); } + + private byte[] toSigningKey(String secret) { + byte[] secretBytes = secret.getBytes(StandardCharsets.UTF_8); + + if (secretBytes.length >= 32) { + return secretBytes; + } + + try { + return MessageDigest.getInstance("SHA-256").digest(secretBytes); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 알고리즘을 사용할 수 없습니다.", exception); + } + } } diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalResultFactory.java b/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalResultFactory.java new file mode 100644 index 0000000..aa6973b --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalResultFactory.java @@ -0,0 +1,73 @@ +package com.festivalapp.service; + +import com.festivalapp.domain.booth.reservation.BoothReservation; +import com.festivalapp.domain.booth.reservation.BoothReservationStatus; +import com.festivalapp.dto.BoothReservationCompensationLogResponse; +import com.festivalapp.dto.BoothReservationResponse; +import com.festivalapp.dto.BoothReservationSagaLogResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class BoothReservationApprovalResultFactory { + + public BoothReservationSagaLogResponse approvalLog(String approverName, LocalDateTime approvedAt) { + return new BoothReservationSagaLogResponse( + "APPROVED", + approverName + "가 예약 신청을 승인했습니다.", + approvedAt); + } + + public BoothReservationResponse qrIssued( + BoothReservation reservation, + BoothReservationSagaLogResponse approvalLog, + LocalDateTime issuedAt) { + return BoothReservationResponse.from( + reservation, + List.of( + approvalLog, + new BoothReservationSagaLogResponse( + "QR_ISSUED", + "QR 발급에 성공해 예약 완료 상태로 전환했습니다.", + issuedAt))); + } + + public BoothReservationResponse qrIssueFailed( + BoothReservation reservation, + BoothReservationSagaLogResponse approvalLog, + String failureReason, + LocalDateTime failedAt) { + return BoothReservationResponse.from( + reservation, + List.of( + approvalLog, + new BoothReservationSagaLogResponse( + "QR_ISSUE_FAILED", + failureReason, + failedAt), + new BoothReservationSagaLogResponse( + "APPROVAL_COMPENSATED", + "QR 발급 실패로 승인 상태를 보상 처리했습니다.", + failedAt)), + compensationLogs(failureReason, failedAt)); + } + + private List compensationLogs( + String failureReason, + LocalDateTime failedAt) { + return List.of( + new BoothReservationCompensationLogResponse( + "APPROVAL_ROLLBACK", + failureReason, + BoothReservationStatus.APPROVED.name(), + BoothReservationStatus.QR_FAILED.name(), + failedAt), + new BoothReservationCompensationLogResponse( + "RETRY_REQUIRED", + "관리자 재승인이 필요합니다.", + BoothReservationStatus.QR_FAILED.name(), + BoothReservationStatus.QR_FAILED.name(), + failedAt)); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalService.java b/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalService.java index 88ac866..c0f07d9 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalService.java +++ b/festival-app/backend/src/main/java/com/festivalapp/service/BoothReservationApprovalService.java @@ -1,11 +1,12 @@ package com.festivalapp.service; import com.festivalapp.domain.booth.reservation.BoothReservation; -import com.festivalapp.domain.booth.reservation.BoothReservationStatus; import com.festivalapp.dto.BoothReservationApprovalRequest; import com.festivalapp.dto.BoothReservationResponse; import com.festivalapp.dto.BoothReservationSagaLogResponse; import com.festivalapp.repository.booth.reservation.BoothReservationRepository; +import com.festivalapp.service.qr.QrCodeIssueCommand; +import com.festivalapp.service.qr.QrCodeIssueException; import com.festivalapp.service.qr.QrCodeIssuer; import java.time.LocalDateTime; import java.util.List; @@ -21,10 +22,12 @@ public class BoothReservationApprovalService { private final BoothReservationRepository boothReservationRepository; private final QrCodeIssuer qrCodeIssuer; + private final BoothReservationApprovalResultFactory resultFactory; @Transactional(readOnly = true) public List getPendingReservations() { - return boothReservationRepository.findByStatus(BoothReservationStatus.PENDING_APPROVAL).stream() + return boothReservationRepository.findAll().stream() + .filter(BoothReservation::canApprove) .map(BoothReservationResponse::from) .toList(); } @@ -38,28 +41,54 @@ public BoothReservationResponse approveReservation( } BoothReservation reservation = boothReservationRepository.findById(reservationId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "예약 정보를 찾을 수 없습니다.")); + .orElseThrow( + () -> new ResponseStatusException(HttpStatus.NOT_FOUND, "예약 정보를 찾을 수 없습니다.")); - if (reservation.status() != BoothReservationStatus.PENDING_APPROVAL) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "승인 대기 상태의 예약만 승인할 수 있습니다."); + if (!reservation.canApprove()) { + throw new ResponseStatusException( + HttpStatus.CONFLICT, + "승인 대기 또는 QR 발급 실패 상태의 예약만 승인할 수 있습니다."); } LocalDateTime now = LocalDateTime.now(); reservation.approve(now); + BoothReservationSagaLogResponse approvalLog = + resultFactory.approvalLog(request.approverName(), now); - String qrCode = qrCodeIssuer.issue(reservation); - reservation.reserve(qrCode, now); + try { + return reserveAfterQrIssued(reservation, request, now, approvalLog); + } catch (QrCodeIssueException exception) { + return compensateQrIssueFailure(reservation, approvalLog, exception); + } + } + + private BoothReservationResponse reserveAfterQrIssued( + BoothReservation reservation, + BoothReservationApprovalRequest request, + LocalDateTime approvedAt, + BoothReservationSagaLogResponse approvalLog) { + String qrCode = qrCodeIssuer.issue(new QrCodeIssueCommand( + reservation, + request.shouldSimulateQrFailure())); + reservation.reserve(qrCode, approvedAt); BoothReservation savedReservation = boothReservationRepository.update(reservation); - return BoothReservationResponse.from(savedReservation, List.of( - new BoothReservationSagaLogResponse( - "APPROVED", - request.approverName() + "가 예약 신청을 승인했습니다.", - now), - new BoothReservationSagaLogResponse( - "QR_ISSUED", - "QR 발급에 성공해 예약 완료 상태로 전환했습니다.", - now))); + return resultFactory.qrIssued(savedReservation, approvalLog, approvedAt); + } + + private BoothReservationResponse compensateQrIssueFailure( + BoothReservation reservation, + BoothReservationSagaLogResponse approvalLog, + QrCodeIssueException exception) { + LocalDateTime failedAt = LocalDateTime.now(); + reservation.compensateApprovalAfterQrFailure(failedAt); + BoothReservation savedReservation = boothReservationRepository.update(reservation); + + return resultFactory.qrIssueFailed( + savedReservation, + approvalLog, + exception.getMessage(), + failedAt); } private boolean isBlank(String value) { diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/auth/AuthService.java b/festival-app/backend/src/main/java/com/festivalapp/service/auth/AuthService.java index d2af8ca..bf5f74e 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/service/auth/AuthService.java +++ b/festival-app/backend/src/main/java/com/festivalapp/service/auth/AuthService.java @@ -23,6 +23,14 @@ public class AuthService { private final JwtTokenProvider jwtTokenProvider; public AuthResponse signup(SignupRequest request) { + return signupWithRole(request, UserRole.USER); + } + + public AuthResponse signupAdmin(SignupRequest request) { + return signupWithRole(request, UserRole.ADMIN); + } + + private AuthResponse signupWithRole(SignupRequest request, UserRole role) { String email = normalizeEmail(request.email()); if (userRepository.existsByEmail(email)) { @@ -35,7 +43,7 @@ public AuthResponse signup(SignupRequest request) { request.name(), email, passwordEncoder.encode(request.password()), - resolveRole(request.role())); + role); User savedUser = userRepository.save(user); return toAuthResponse(savedUser); } @@ -61,10 +69,6 @@ private AuthResponse toAuthResponse(User user) { return new AuthResponse(jwtTokenProvider.createToken(user), UserResponse.from(user)); } - private UserRole resolveRole(UserRole requestedRole) { - return requestedRole == null ? UserRole.USER : requestedRole; - } - private String normalizeEmail(String email) { return email.trim().toLowerCase(); } diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/qr/InMemoryQrCodeIssuer.java b/festival-app/backend/src/main/java/com/festivalapp/service/qr/InMemoryQrCodeIssuer.java index 79733c2..9d1578e 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/service/qr/InMemoryQrCodeIssuer.java +++ b/festival-app/backend/src/main/java/com/festivalapp/service/qr/InMemoryQrCodeIssuer.java @@ -15,7 +15,12 @@ public InMemoryQrCodeIssuer( } @Override - public String issue(BoothReservation reservation) { + public String issue(QrCodeIssueCommand command) { + if (command.simulateFailure()) { + throw new QrCodeIssueException("QR 발급 시뮬레이션 실패"); + } + + BoothReservation reservation = command.reservation(); return frontendBaseUrl + "/booths/reservations/" + reservation.id(); } } diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueCommand.java b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueCommand.java new file mode 100644 index 0000000..3a5d87a --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueCommand.java @@ -0,0 +1,7 @@ +package com.festivalapp.service.qr; + +import com.festivalapp.domain.booth.reservation.BoothReservation; + +public record QrCodeIssueCommand( + BoothReservation reservation, + boolean simulateFailure) {} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueException.java b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueException.java new file mode 100644 index 0000000..57934c3 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssueException.java @@ -0,0 +1,8 @@ +package com.festivalapp.service.qr; + +public class QrCodeIssueException extends RuntimeException { + + public QrCodeIssueException(String message) { + super(message); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssuer.java b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssuer.java index 7245342..c351dfe 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssuer.java +++ b/festival-app/backend/src/main/java/com/festivalapp/service/qr/QrCodeIssuer.java @@ -1,8 +1,6 @@ package com.festivalapp.service.qr; -import com.festivalapp.domain.booth.reservation.BoothReservation; - public interface QrCodeIssuer { - String issue(BoothReservation reservation); + String issue(QrCodeIssueCommand command); } diff --git a/festival-app/backend/src/main/resources/application.yml b/festival-app/backend/src/main/resources/application.yml index 1e99b1f..3d06a93 100644 --- a/festival-app/backend/src/main/resources/application.yml +++ b/festival-app/backend/src/main/resources/application.yml @@ -11,5 +11,5 @@ app: - http://localhost:3000 - http://127.0.0.1:3000 jwt: - secret: ${JWT_SECRET} + secret: ${JWT_SECRET:local-development-jwt-secret-key-32bytes} expiration-millis: ${JWT_EXPIRATION_MILLIS:3600000} diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationControllerTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationControllerTest.java index ddaa43f..3e76c5a 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationControllerTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationControllerTest.java @@ -1,5 +1,6 @@ package com.festivalapp.api; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -66,6 +67,27 @@ void approveReservationReturnsReservedReservationWithQrCode() throws Exception { .value("http://localhost:3000/booths/reservations/reservation-1")); } + @Test + void approveReservationCanReturnQrFailureCompensationResult() throws Exception { + given(approvalService.approveReservation( + org.mockito.ArgumentMatchers.eq("reservation-1"), + org.mockito.ArgumentMatchers.any(BoothReservationApprovalRequest.class))) + .willReturn(reservationResponse("reservation-1", "QR_FAILED", null)); + + mockMvc.perform(post("/api/admin/booth-reservations/reservation-1/approve") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "approverId": "admin-1", + "approverName": "관리자", + "simulateQrFailure": true + } + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("QR_FAILED")) + .andExpect(jsonPath("$.qrCode").value(nullValue())); + } + private BoothReservationResponse reservationResponse(String id, String status, String qrCode) { LocalDateTime now = LocalDateTime.of(2026, 5, 24, 10, 0); return new BoothReservationResponse( @@ -78,6 +100,7 @@ private BoothReservationResponse reservationResponse(String id, String status, S status, qrCode, List.of(), + List.of(), now, now); } diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationSecurityTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationSecurityTest.java index eaaab47..0e6e91d 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationSecurityTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/api/AdminBoothReservationSecurityTest.java @@ -48,18 +48,18 @@ void adminReservationApiRequiresAdminRole() throws Exception { } private String signupAndGetToken(String email, String role) throws Exception { + String signupPath = "ADMIN".equals(role) ? "/api/admin/auth/signup" : "/api/auth/signup"; MvcResult result = mockMvc .perform( - post("/api/auth/signup") + post(signupPath) .contentType(MediaType.APPLICATION_JSON) .content( objectMapper.writeValueAsString( Map.of( "name", "테스트", "email", email, - "password", "password123", - "role", role)))) + "password", "password123")))) .andExpect(status().isOk()) .andExpect(jsonPath("$.accessToken", not(blankOrNullString()))) .andReturn(); diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/AuthControllerTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/AuthControllerTest.java index 4ecc587..bf7afcd 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/api/AuthControllerTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/api/AuthControllerTest.java @@ -46,7 +46,7 @@ void signupReturnsAccessTokenAndUser() throws Exception { } @Test - void adminSignupReturnsAdminRole() throws Exception { + void signupIgnoresAdminRoleAndReturnsUser() throws Exception { mockMvc .perform( post("/api/auth/signup") @@ -59,6 +59,22 @@ void adminSignupReturnsAdminRole() throws Exception { "password", "password123", "role", "ADMIN")))) .andExpect(status().isOk()) + .andExpect(jsonPath("$.user.role").value("USER")); + } + + @Test + void adminSignupReturnsAdminRole() throws Exception { + mockMvc + .perform( + post("/api/admin/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + Map.of( + "name", "관리자", + "email", "admin-only-signup@hongik.ac.kr", + "password", "password123")))) + .andExpect(status().isOk()) .andExpect(jsonPath("$.user.role").value("ADMIN")); } diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/BoothReservationControllerTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/BoothReservationControllerTest.java index 4832d60..2ff0f26 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/api/BoothReservationControllerTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/api/BoothReservationControllerTest.java @@ -8,14 +8,18 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import com.festivalapp.domain.auth.UserRole; import com.festivalapp.dto.BoothReservationCreateRequest; import com.festivalapp.dto.BoothReservationResponse; +import com.festivalapp.security.AuthenticatedUser; import com.festivalapp.service.BoothReservationService; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; @@ -37,6 +41,7 @@ void createReservationReturnsCreatedPendingApprovalReservation() throws Exceptio .willReturn(reservationResponse("reservation-1", "user-1")); mockMvc.perform(post("/api/booth-reservations") + .principal(authenticatedUser("user-1")) .contentType(MediaType.APPLICATION_JSON) .content(""" { @@ -57,12 +62,20 @@ void getReservationsByApplicantReturnsUsersReservations() throws Exception { given(boothReservationService.getReservationsByApplicant("user-1")) .willReturn(List.of(reservationResponse("reservation-1", "user-1"))); - mockMvc.perform(get("/api/booth-reservations/applicants/user-1")) + mockMvc.perform(get("/api/booth-reservations/applicants/user-1") + .principal(authenticatedUser("user-1"))) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value("reservation-1")) .andExpect(jsonPath("$[0].applicantId").value("user-1")); } + @Test + void getReservationsByApplicantRejectsOtherUser() throws Exception { + mockMvc.perform(get("/api/booth-reservations/applicants/user-2") + .principal(authenticatedUser("user-1"))) + .andExpect(status().isForbidden()); + } + @Test void getReservationReturnsReservation() throws Exception { given(boothReservationService.getReservation("reservation-1")) @@ -85,7 +98,14 @@ private BoothReservationResponse reservationResponse(String id, String applicant "관리자 승인 대기", null, List.of(), + List.of(), now, now); } + + private Authentication authenticatedUser(String userId) { + return new TestingAuthenticationToken( + new AuthenticatedUser(userId, userId + "@hongik.ac.kr", UserRole.USER), + null); + } } diff --git a/festival-app/backend/src/test/java/com/festivalapp/domain/booth/reservation/BoothReservationStatusTest.java b/festival-app/backend/src/test/java/com/festivalapp/domain/booth/reservation/BoothReservationStatusTest.java index 2cfc256..bae301d 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/domain/booth/reservation/BoothReservationStatusTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/domain/booth/reservation/BoothReservationStatusTest.java @@ -17,4 +17,10 @@ void reservedHasDescription() { assertThat(BoothReservationStatus.RESERVED.getDescription()) .isEqualTo("QR 발급 완료"); } + + @Test + void qrFailedHasDescription() { + assertThat(BoothReservationStatus.QR_FAILED.getDescription()) + .isEqualTo("QR 발급 실패"); + } } diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalResultFactoryTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalResultFactoryTest.java new file mode 100644 index 0000000..41ebf71 --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalResultFactoryTest.java @@ -0,0 +1,62 @@ +package com.festivalapp.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festivalapp.domain.booth.reservation.BoothReservation; +import com.festivalapp.domain.booth.reservation.BoothReservationStatus; +import com.festivalapp.dto.BoothReservationCompensationLogResponse; +import com.festivalapp.dto.BoothReservationResponse; +import com.festivalapp.dto.BoothReservationSagaLogResponse; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class BoothReservationApprovalResultFactoryTest { + + private final BoothReservationApprovalResultFactory factory = + new BoothReservationApprovalResultFactory(); + + @Test + void qrIssuedBuildsSuccessSagaResponse() { + LocalDateTime now = LocalDateTime.of(2026, 5, 24, 10, 0); + BoothReservation reservation = reservation(BoothReservationStatus.RESERVED); + BoothReservationSagaLogResponse approvalLog = factory.approvalLog("관리자", now); + + BoothReservationResponse response = factory.qrIssued(reservation, approvalLog, now); + + assertThat(response.sagaLogs()) + .extracting(BoothReservationSagaLogResponse::step) + .containsExactly("APPROVED", "QR_ISSUED"); + assertThat(response.compensationLogs()).isEmpty(); + } + + @Test + void qrIssueFailedBuildsCompensationResponse() { + LocalDateTime now = LocalDateTime.of(2026, 5, 24, 10, 0); + BoothReservation reservation = reservation(BoothReservationStatus.QR_FAILED); + BoothReservationSagaLogResponse approvalLog = factory.approvalLog("관리자", now); + + BoothReservationResponse response = + factory.qrIssueFailed(reservation, approvalLog, "QR 발급 시뮬레이션 실패", now); + + assertThat(response.sagaLogs()) + .extracting(BoothReservationSagaLogResponse::step) + .containsExactly("APPROVED", "QR_ISSUE_FAILED", "APPROVAL_COMPENSATED"); + assertThat(response.compensationLogs()) + .extracting(BoothReservationCompensationLogResponse::step) + .containsExactly("APPROVAL_ROLLBACK", "RETRY_REQUIRED"); + } + + private BoothReservation reservation(BoothReservationStatus status) { + LocalDateTime now = LocalDateTime.of(2026, 5, 24, 10, 0); + return new BoothReservation( + "reservation-1", + "booth-1", + "user-1", + "홍길동", + 2, + status, + null, + now, + now); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalServiceTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalServiceTest.java index fbe1624..78c4b76 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalServiceTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/service/BoothReservationApprovalServiceTest.java @@ -6,6 +6,7 @@ import com.festivalapp.domain.booth.reservation.BoothReservation; import com.festivalapp.domain.booth.reservation.BoothReservationStatus; import com.festivalapp.dto.BoothReservationApprovalRequest; +import com.festivalapp.dto.BoothReservationCompensationLogResponse; import com.festivalapp.dto.BoothReservationResponse; import com.festivalapp.dto.BoothReservationSagaLogResponse; import com.festivalapp.repository.booth.reservation.BoothReservationRepository; @@ -23,12 +24,14 @@ class BoothReservationApprovalServiceTest { void getPendingReservationsReturnsOnlyPendingApprovalReservations() { BoothReservationApprovalService service = serviceWith(new ArrayList<>(List.of( reservation("reservation-1", BoothReservationStatus.PENDING_APPROVAL), - reservation("reservation-2", BoothReservationStatus.RESERVED)))); + reservation("reservation-2", BoothReservationStatus.QR_FAILED), + reservation("reservation-3", BoothReservationStatus.RESERVED)))); List responses = service.getPendingReservations(); - assertThat(responses).hasSize(1); - assertThat(responses.get(0).id()).isEqualTo("reservation-1"); + assertThat(responses) + .extracting(BoothReservationResponse::id) + .containsExactly("reservation-1", "reservation-2"); } @Test @@ -48,6 +51,61 @@ void approveReservationIssuesQrAndChangesStatusToReserved() { assertThat(response.sagaLogs()) .extracting(BoothReservationSagaLogResponse::step) .containsExactly("APPROVED", "QR_ISSUED"); + assertThat(response.compensationLogs()).isEmpty(); + assertThat(reservations.get(0).status()).isEqualTo(BoothReservationStatus.RESERVED); + } + + @Test + void approveReservationRollsBackApprovalWhenQrIssueFails() { + List reservations = new ArrayList<>(List.of( + reservation("reservation-1", BoothReservationStatus.PENDING_APPROVAL))); + BoothReservationApprovalService service = serviceWith(reservations); + + BoothReservationResponse response = service.approveReservation( + "reservation-1", + new BoothReservationApprovalRequest("admin-1", "관리자", true)); + + assertThat(response.status()).isEqualTo("QR_FAILED"); + assertThat(response.statusDescription()).isEqualTo("QR 발급 실패"); + assertThat(response.qrCode()).isNull(); + assertThat(response.sagaLogs()) + .extracting(BoothReservationSagaLogResponse::step) + .containsExactly("APPROVED", "QR_ISSUE_FAILED", "APPROVAL_COMPENSATED"); + assertThat(response.compensationLogs()) + .extracting(BoothReservationCompensationLogResponse::step) + .containsExactly("APPROVAL_ROLLBACK", "RETRY_REQUIRED"); + assertThat(response.compensationLogs().get(0).fromStatus()).isEqualTo("APPROVED"); + assertThat(response.compensationLogs().get(0).toStatus()).isEqualTo("QR_FAILED"); + } + + @Test + void approveReservationStoresQrFailedStatusWhenQrIssueFails() { + List reservations = new ArrayList<>(List.of( + reservation("reservation-1", BoothReservationStatus.PENDING_APPROVAL))); + BoothReservationApprovalService service = serviceWith(reservations); + + BoothReservationResponse response = service.approveReservation( + "reservation-1", + new BoothReservationApprovalRequest("admin-1", "관리자", true)); + + assertThat(response.status()).isEqualTo("QR_FAILED"); + assertThat(response.statusDescription()).isEqualTo("QR 발급 실패"); + assertThat(response.qrCode()).isNull(); + assertThat(reservations.get(0).status()).isEqualTo(BoothReservationStatus.QR_FAILED); + assertThat(reservations.get(0).qrCode()).isNull(); + } + + @Test + void approveReservationAllowsRetryAfterQrIssueFailure() { + List reservations = new ArrayList<>(List.of( + reservation("reservation-1", BoothReservationStatus.QR_FAILED))); + BoothReservationApprovalService service = serviceWith(reservations); + + BoothReservationResponse response = service.approveReservation( + "reservation-1", + new BoothReservationApprovalRequest("admin-1", "관리자")); + + assertThat(response.status()).isEqualTo("RESERVED"); assertThat(reservations.get(0).status()).isEqualTo(BoothReservationStatus.RESERVED); } @@ -81,7 +139,14 @@ private BoothReservationApprovalService serviceWith(List reser new BoothReservationRepository(new TestBoothReservationDataSource(reservations)); return new BoothReservationApprovalService( repository, - reservation -> "http://localhost:3000/booths/reservations/" + reservation.id()); + command -> { + if (command.simulateFailure()) { + throw new com.festivalapp.service.qr.QrCodeIssueException("QR 발급 시뮬레이션 실패"); + } + + return "http://localhost:3000/booths/reservations/" + command.reservation().id(); + }, + new BoothReservationApprovalResultFactory()); } private BoothReservation reservation(String id, BoothReservationStatus status) { diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/auth/AuthServiceTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/auth/AuthServiceTest.java index d91e10e..07c86d1 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/service/auth/AuthServiceTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/service/auth/AuthServiceTest.java @@ -36,13 +36,25 @@ void signupCreatesUserAndReturnsToken() { } @Test - void signupCreatesAdminUserWhenRoleIsAdmin() { + void signupCreatesUserEvenWhenRoleIsAdmin() { AuthResponse response = authService.signup( new SignupRequest("관리자", "admin@hongik.ac.kr", "password123", UserRole.ADMIN)); - assertThat(response.user().role()).isEqualTo(UserRole.ADMIN); + assertThat(response.user().role()).isEqualTo(UserRole.USER); assertThat(userRepository.findByEmail("admin@hongik.ac.kr")) + .get() + .extracting("role") + .isEqualTo(UserRole.USER); + } + + @Test + void signupAdminCreatesAdminUser() { + AuthResponse response = + authService.signupAdmin(new SignupRequest("관리자", "real-admin@hongik.ac.kr", "password123")); + + assertThat(response.user().role()).isEqualTo(UserRole.ADMIN); + assertThat(userRepository.findByEmail("real-admin@hongik.ac.kr")) .get() .extracting("role") .isEqualTo(UserRole.ADMIN); diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/qr/InMemoryQrCodeIssuerTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/qr/InMemoryQrCodeIssuerTest.java index e8ecb3e..d0eea53 100644 --- a/festival-app/backend/src/test/java/com/festivalapp/service/qr/InMemoryQrCodeIssuerTest.java +++ b/festival-app/backend/src/test/java/com/festivalapp/service/qr/InMemoryQrCodeIssuerTest.java @@ -1,6 +1,7 @@ package com.festivalapp.service.qr; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.festivalapp.domain.booth.reservation.BoothReservation; import com.festivalapp.domain.booth.reservation.BoothReservationStatus; @@ -13,12 +14,21 @@ class InMemoryQrCodeIssuerTest { void issueReturnsReservationVerificationUrl() { InMemoryQrCodeIssuer issuer = new InMemoryQrCodeIssuer("https://festival.example.com"); - String qrCode = issuer.issue(reservation()); + String qrCode = issuer.issue(new QrCodeIssueCommand(reservation(), false)); assertThat(qrCode) .isEqualTo("https://festival.example.com/booths/reservations/reservation-1"); } + @Test + void issueThrowsExceptionWhenFailureIsSimulated() { + InMemoryQrCodeIssuer issuer = new InMemoryQrCodeIssuer("https://festival.example.com"); + + assertThatThrownBy(() -> issuer.issue(new QrCodeIssueCommand(reservation(), true))) + .isInstanceOf(QrCodeIssueException.class) + .hasMessage("QR 발급 시뮬레이션 실패"); + } + private BoothReservation reservation() { LocalDateTime now = LocalDateTime.of(2026, 5, 24, 10, 0); return new BoothReservation( diff --git a/festival-app/frontend/src/apis/auth/auth.api.ts b/festival-app/frontend/src/apis/auth/auth.api.ts index 326a852..4d656bc 100644 --- a/festival-app/frontend/src/apis/auth/auth.api.ts +++ b/festival-app/frontend/src/apis/auth/auth.api.ts @@ -5,6 +5,10 @@ export async function signup(request: SignupRequest) { return apiClient.post("api/auth/signup", { json: request }).json(); } +export async function signupAdmin(request: SignupRequest) { + return apiClient.post("api/admin/auth/signup", { json: request }).json(); +} + export async function login(request: LoginRequest) { return apiClient.post("api/auth/login", { json: request }).json(); } diff --git a/festival-app/frontend/src/app/admin/signup/page.tsx b/festival-app/frontend/src/app/admin/signup/page.tsx new file mode 100644 index 0000000..ea5c8e8 --- /dev/null +++ b/festival-app/frontend/src/app/admin/signup/page.tsx @@ -0,0 +1,122 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import SiteHeader from "@/components/SiteHeader"; +import { useAuth } from "@/providers/AuthProvider"; +import { getAuthErrorMessage } from "@/utils/auth-error"; + +export default function AdminSignupPage() { + const router = useRouter(); + const { signupAdmin } = useAuth(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setErrorMessage(""); + setIsSubmitting(true); + + try { + await signupAdmin({ name, email, password }); + router.replace("/booths/admin/reservations"); + } catch (error) { + setErrorMessage(await getAuthErrorMessage(error)); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+ ); +} diff --git a/festival-app/frontend/src/app/signup/page.tsx b/festival-app/frontend/src/app/signup/page.tsx index f80e0ab..f82d659 100644 --- a/festival-app/frontend/src/app/signup/page.tsx +++ b/festival-app/frontend/src/app/signup/page.tsx @@ -6,7 +6,6 @@ import { useRouter } from "next/navigation"; import SiteHeader from "@/components/SiteHeader"; import { useAuth } from "@/providers/AuthProvider"; -import type { AuthRole } from "@/types/auth.types"; import { getAuthErrorMessage } from "@/utils/auth-error"; export default function SignupPage() { @@ -15,7 +14,6 @@ export default function SignupPage() { const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [role, setRole] = useState("USER"); const [errorMessage, setErrorMessage] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -25,7 +23,7 @@ export default function SignupPage() { setIsSubmitting(true); try { - await signup({ name, email, password, role }); + await signup({ name, email, password }); router.replace("/"); } catch (error) { setErrorMessage(await getAuthErrorMessage(error)); @@ -96,22 +94,6 @@ export default function SignupPage() { /> - - {errorMessage ? (

{errorMessage} @@ -133,6 +115,12 @@ export default function SignupPage() { 로그인

+

+ 관리자 계정은{" "} + + 관리자 가입 + +

diff --git a/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.test.tsx b/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.test.tsx index cf853b9..77fc012 100644 --- a/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.test.tsx +++ b/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.test.tsx @@ -112,6 +112,7 @@ describe("AdminBoothReservationApproval", () => { expect(mockedApproveBoothReservation).toHaveBeenCalledWith("reservation-1", { approverId: "admin-1", approverName: "관리자", + simulateQrFailure: false, }); expect( await screen.findByText("홍길동님의 예약을 승인하고 QR을 발급했습니다."), @@ -123,6 +124,65 @@ describe("AdminBoothReservationApproval", () => { screen.getByLabelText("예약 QR http://localhost:3000/booths/reservations/reservation-1"), ).toBeInTheDocument(); }); + + it("shows QR failure result and keeps reservation retryable", async () => { + const user = userEvent.setup(); + mockedGetPendingBoothReservations.mockResolvedValue([ + reservationApplication({ id: "reservation-1", applicantName: "홍길동" }), + ]); + mockedApproveBoothReservation.mockResolvedValue( + reservationApplication({ + id: "reservation-1", + applicantName: "홍길동", + status: "QR_FAILED", + statusDescription: "QR 발급 실패", + compensationLogs: [ + { + step: "APPROVAL_ROLLBACK", + reason: "QR 발급 시뮬레이션 실패", + fromStatus: "APPROVED", + toStatus: "QR_FAILED", + createdAt: "2026-05-24T10:05:00", + }, + ], + sagaLogs: [ + { + step: "APPROVED", + message: "관리자가 예약 신청을 승인했습니다.", + createdAt: "2026-05-24T10:05:00", + }, + { + step: "QR_ISSUE_FAILED", + message: "QR 발급 시뮬레이션 실패", + createdAt: "2026-05-24T10:05:00", + }, + { + step: "APPROVAL_COMPENSATED", + message: "QR 발급 실패로 승인 상태를 보상 처리했습니다.", + createdAt: "2026-05-24T10:05:00", + }, + ], + }), + ); + + render(); + + await screen.findByRole("button", { name: /홍길동/ }); + await user.click(screen.getByLabelText("QR 발급 실패 시뮬레이션")); + await user.click(screen.getByRole("button", { name: "승인하고 QR 발급" })); + + expect(mockedApproveBoothReservation).toHaveBeenCalledWith("reservation-1", { + approverId: "admin-1", + approverName: "관리자", + simulateQrFailure: true, + }); + expect( + await screen.findByText("홍길동님의 예약 승인 중 QR 발급이 실패해 보상 처리했습니다."), + ).toBeInTheDocument(); + expect(screen.getAllByText("QR 발급 실패")).not.toHaveLength(0); + expect(screen.getByText("QR 발급 시뮬레이션 실패")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "재승인하고 QR 재발급" })).toBeInTheDocument(); + }); }); function reservationApplication( @@ -138,6 +198,7 @@ function reservationApplication( statusDescription: "관리자 승인 대기", qrCode: null, sagaLogs: [], + compensationLogs: [], createdAt: "2026-05-24T10:00:00", updatedAt: "2026-05-24T10:00:00", ...overrides, diff --git a/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.tsx b/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.tsx index 15cd561..287ce10 100644 --- a/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.tsx +++ b/festival-app/frontend/src/components/booths/admin-booth-reservation-approval.tsx @@ -17,6 +17,7 @@ const ADMIN_APPROVER = { id: "admin-1", name: "관리자", }; +const QR_FAILURE_SIMULATION_INPUT_ID = "qr-failure-simulation"; export function AdminBoothReservationApproval() { const { isAuthenticated, isInitialized, user } = useAuth(); @@ -27,6 +28,7 @@ export function AdminBoothReservationApproval() { const [selectedReservationId, setSelectedReservationId] = useState(""); const [message, setMessage] = useState(""); const [errorMessage, setErrorMessage] = useState(""); + const [simulateQrFailure, setSimulateQrFailure] = useState(false); const [isLoading, setIsLoading] = useState(true); const [isApproving, setIsApproving] = useState(false); @@ -90,16 +92,29 @@ export function AdminBoothReservationApproval() { const approvedReservation = await approveBoothReservation(selectedReservation.id, { approverId: ADMIN_APPROVER.id, approverName: ADMIN_APPROVER.name, + simulateQrFailure, }); - setPendingReservations((currentReservations) => - currentReservations.filter((reservation) => reservation.id !== approvedReservation.id), - ); + if (approvedReservation.status === "QR_FAILED") { + setPendingReservations((currentReservations) => + currentReservations.map((reservation) => + reservation.id === approvedReservation.id ? approvedReservation : reservation, + ), + ); + } else { + setPendingReservations((currentReservations) => + currentReservations.filter((reservation) => reservation.id !== approvedReservation.id), + ); + } setApprovedReservations((currentReservations) => [ approvedReservation, ...currentReservations, ]); setSelectedReservationId((currentId) => { + if (approvedReservation.status === "QR_FAILED") { + return approvedReservation.id; + } + if (currentId !== approvedReservation.id) { return currentId; } @@ -109,7 +124,7 @@ export function AdminBoothReservationApproval() { ?.id ?? "" ); }); - setMessage(`${approvedReservation.applicantName}님의 예약을 승인하고 QR을 발급했습니다.`); + setMessage(resolveApprovalSuccessMessage(approvedReservation)); } catch (error) { setErrorMessage(await resolveApprovalErrorMessage(error)); } finally { @@ -237,6 +252,9 @@ export function AdminBoothReservationApproval() { ) : null} + {reservation.status === "QR_FAILED" ? ( + + ) : null} )) ) : ( @@ -272,6 +290,28 @@ export function AdminBoothReservationApproval() {
{selectedReservation.statusDescription}
+ {selectedReservation.status === "QR_FAILED" ? ( +
+ 이전 QR 발급이 실패했습니다. 재승인하면 QR 발급을 다시 시도합니다. +
+ ) : null} + + ) : ( @@ -293,6 +337,46 @@ export function AdminBoothReservationApproval() { ); } +function QrFailureResult({ reservation }: { reservation: BoothReservationApplication }) { + const latestCompensation = reservation.compensationLogs[0]; + + return ( +
+

QR 발급 실패

+

+ 승인 상태가 보상 처리되었습니다. 관리자는 같은 예약을 다시 선택해 QR 발급을 재시도할 수 + 있습니다. +

+ {latestCompensation ? ( +
+
+
보상 단계
+
{latestCompensation.step}
+
+
+
상태 변경
+
+ {latestCompensation.fromStatus} → {latestCompensation.toStatus} +
+
+
+
원인
+
{latestCompensation.reason}
+
+
+ ) : null} +
+ ); +} + +function resolveApprovalSuccessMessage(reservation: BoothReservationApplication) { + if (reservation.status === "QR_FAILED") { + return `${reservation.applicantName}님의 예약 승인 중 QR 발급이 실패해 보상 처리했습니다.`; + } + + return `${reservation.applicantName}님의 예약을 승인하고 QR을 발급했습니다.`; +} + function AdminAccessMessage({ title, description }: { title: string; description: string }) { return (
diff --git a/festival-app/frontend/src/components/booths/booth-directory.test.tsx b/festival-app/frontend/src/components/booths/booth-directory.test.tsx index 5aad28e..5f6e38d 100644 --- a/festival-app/frontend/src/components/booths/booth-directory.test.tsx +++ b/festival-app/frontend/src/components/booths/booth-directory.test.tsx @@ -8,8 +8,13 @@ import { getBooths, } from "@/apis/booths/booth.api"; import { BoothDirectory } from "@/components/booths/booth-directory"; +import type { AuthUser } from "@/types/auth.types"; import type { Booth, BoothReservationApplication } from "@/types/booth/booths.types"; +const authState = vi.hoisted(() => ({ + user: null as AuthUser | null, +})); + vi.mock("@/apis/booths/booth.api", () => ({ createBoothReservation: vi.fn(), getBoothReservationsByApplicant: vi.fn(), @@ -20,6 +25,19 @@ vi.mock("next/navigation", () => ({ usePathname: () => "/booths", })); +vi.mock("@/providers/AuthProvider", () => ({ + useAuth: () => ({ + accessToken: authState.user ? "access-token" : null, + user: authState.user, + isInitialized: true, + isAuthenticated: Boolean(authState.user), + login: vi.fn(), + signup: vi.fn(), + signupAdmin: vi.fn(), + logout: vi.fn(), + }), +})); + const booths: Booth[] = [ { id: "booth-1", @@ -62,7 +80,7 @@ const mockedGetBoothReservationsByApplicant = vi.mocked(getBoothReservationsByAp describe("BoothDirectory", () => { beforeEach(() => { vi.clearAllMocks(); - window.localStorage.clear(); + authState.user = null; mockedGetBoothReservationsByApplicant.mockResolvedValue([]); }); @@ -131,7 +149,7 @@ describe("BoothDirectory", () => { expect(mockedCreateBoothReservation).toHaveBeenCalledWith({ boothId: "booth-1", - applicantId: "demo-user-1", + applicantId: "user-1", applicantName: "홍길동", requestedTables: 2, }); @@ -204,12 +222,13 @@ describe("BoothDirectory", () => { mockedGetBooths.mockResolvedValue(booths); mockedGetBoothReservationsByApplicant.mockResolvedValue([reservationApplication()]); - render(); + const { rerender } = render(); expect(await screen.findByText("신청 테이블 2개")).toBeInTheDocument(); await act(async () => { - window.localStorage.clear(); + authState.user = null; + rerender(); }); expect( @@ -238,6 +257,32 @@ describe("BoothDirectory", () => { screen.getByLabelText("예약 QR http://localhost:3000/booths/reservations/reservation-1"), ).toBeInTheDocument(); }); + + it("shows QR failure status and retry guidance for failed reservation", async () => { + setLoggedInApplicant(); + mockedGetBooths.mockResolvedValue(booths); + mockedGetBoothReservationsByApplicant.mockResolvedValue([ + reservationApplication({ + status: "QR_FAILED", + statusDescription: "QR 발급 실패", + compensationLogs: [ + { + step: "APPROVAL_ROLLBACK", + reason: "QR 발급 시뮬레이션 실패", + fromStatus: "APPROVED", + toStatus: "QR_FAILED", + createdAt: "2026-05-24T10:05:00", + }, + ], + }), + ]); + + render(); + + expect(await screen.findAllByText("QR 발급 실패")).not.toHaveLength(0); + expect(screen.getAllByText("관리자 재승인 후 QR 재발급이 필요합니다.")).not.toHaveLength(0); + expect(screen.getAllByText("QR 발급 시뮬레이션 실패")).not.toHaveLength(0); + }); }); function reservationApplication( @@ -253,6 +298,7 @@ function reservationApplication( statusDescription: "관리자 승인 대기", qrCode: null, sagaLogs: [], + compensationLogs: [], createdAt: "2026-05-24T10:00:00", updatedAt: "2026-05-24T10:00:00", ...overrides, @@ -260,11 +306,10 @@ function reservationApplication( } function setLoggedInApplicant() { - window.localStorage.setItem( - "festival-app-current-user", - JSON.stringify({ - id: "demo-user-1", - name: "홍길동", - }), - ); + authState.user = { + id: "user-1", + name: "홍길동", + email: "user1@hongik.ac.kr", + role: "USER", + }; } diff --git a/festival-app/frontend/src/components/booths/booth-directory.tsx b/festival-app/frontend/src/components/booths/booth-directory.tsx index f6512f3..ab27542 100644 --- a/festival-app/frontend/src/components/booths/booth-directory.tsx +++ b/festival-app/frontend/src/components/booths/booth-directory.tsx @@ -1,29 +1,18 @@ "use client"; -import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getBoothReservationsByApplicant, getBooths } from "@/apis/booths/booth.api"; import { BoothCard } from "@/components/booths/booth-card"; import { BoothDetailPanel } from "@/components/booths/booth-detail-panel"; import { BoothReservationListPanel } from "@/components/booths/booth-reservation-list-panel"; import SiteHeader from "@/components/SiteHeader"; -import { - getApplicantSnapshot, - parseApplicantSnapshot, - subscribeToApplicantChange, -} from "@/lib/current-applicant"; +import { useAuth } from "@/providers/AuthProvider"; import type { Booth, BoothReservationApplication } from "@/types/booth/booths.types"; export function BoothDirectory() { - const applicantSnapshot = useSyncExternalStore( - subscribeToApplicantChange, - getApplicantSnapshot, - () => null, - ); - const currentApplicant = useMemo( - () => parseApplicantSnapshot(applicantSnapshot), - [applicantSnapshot], - ); + const { isInitialized, user } = useAuth(); + const currentApplicant = useMemo(() => (user ? { id: user.id, name: user.name } : null), [user]); const [booths, setBooths] = useState([]); const [reservations, setReservations] = useState([]); const [selectedBoothId, setSelectedBoothId] = useState(""); @@ -71,7 +60,7 @@ export function BoothDirectory() { }, []); useEffect(() => { - if (!currentApplicant) { + if (!isInitialized || !currentApplicant) { queueMicrotask(() => setReservations([])); return; } @@ -95,7 +84,7 @@ export function BoothDirectory() { isActive = false; controller.abort(); }; - }, [currentApplicant]); + }, [currentApplicant, isInitialized]); function handleReservationCreated(reservation: BoothReservationApplication) { setReservations((currentReservations) => [reservation, ...currentReservations]); diff --git a/festival-app/frontend/src/components/booths/booth-reservation-application-panel.tsx b/festival-app/frontend/src/components/booths/booth-reservation-application-panel.tsx index 5f6ff10..503bbb1 100644 --- a/festival-app/frontend/src/components/booths/booth-reservation-application-panel.tsx +++ b/festival-app/frontend/src/components/booths/booth-reservation-application-panel.tsx @@ -164,6 +164,19 @@ export function BoothReservationApplicationPanel({ ) : null} + {selectedBoothReservation.status === "QR_FAILED" ? ( +
+

QR 발급 실패

+

+ 관리자 재승인 후 QR 재발급이 필요한 상태입니다. +

+ {selectedBoothReservation.compensationLogs[0] ? ( +

+ {selectedBoothReservation.compensationLogs[0].reason} +

+ ) : null} +
+ ) : null} ) : (

아직 이 부스에 신청한 예약이 없습니다.

diff --git a/festival-app/frontend/src/components/booths/booth-reservation-list-panel.tsx b/festival-app/frontend/src/components/booths/booth-reservation-list-panel.tsx index 4d58503..99939c1 100644 --- a/festival-app/frontend/src/components/booths/booth-reservation-list-panel.tsx +++ b/festival-app/frontend/src/components/booths/booth-reservation-list-panel.tsx @@ -4,7 +4,11 @@ import { boothReservationStatusLabels, boothReservationStatusStyles, } from "@/constants/booths/booth.constants"; -import type { Booth, BoothReservationApplication } from "@/types/booth/booths.types"; +import type { + Booth, + BoothReservationApplication, + BoothReservationCompensationLog, +} from "@/types/booth/booths.types"; type BoothReservationListPanelProps = { booths: Booth[]; @@ -70,6 +74,9 @@ export function BoothReservationListPanel({ {reservation.qrCode} ) : null} + {reservation.status === "QR_FAILED" ? ( + + ) : null} ); } + +function QrFailureSummary({ + compensationLog, +}: { + compensationLog?: BoothReservationCompensationLog; +}) { + return ( + + QR 발급 실패 + 관리자 재승인 후 QR 재발급이 필요합니다. + {compensationLog ? ( + + {compensationLog.reason} · {compensationLog.fromStatus} → {compensationLog.toStatus} + + ) : null} + + ); +} diff --git a/festival-app/frontend/src/components/booths/booth-reservation-verification.test.tsx b/festival-app/frontend/src/components/booths/booth-reservation-verification.test.tsx index 9f56ed1..22ca8a6 100644 --- a/festival-app/frontend/src/components/booths/booth-reservation-verification.test.tsx +++ b/festival-app/frontend/src/components/booths/booth-reservation-verification.test.tsx @@ -30,6 +30,7 @@ describe("BoothReservationVerification", () => { statusDescription: "QR 발급 완료", qrCode: "http://localhost:3000/booths/reservations/reservation-1", sagaLogs: [], + compensationLogs: [], createdAt: "2026-05-24T10:00:00", updatedAt: "2026-05-24T10:05:00", }); diff --git a/festival-app/frontend/src/providers/AuthProvider.tsx b/festival-app/frontend/src/providers/AuthProvider.tsx index e45b691..6ea9fa0 100644 --- a/festival-app/frontend/src/providers/AuthProvider.tsx +++ b/festival-app/frontend/src/providers/AuthProvider.tsx @@ -2,7 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { getMe, login, signup } from "@/apis/auth/auth.api"; +import { getMe, login, signup, signupAdmin } from "@/apis/auth/auth.api"; import type { AuthResponse, AuthUser, LoginRequest, SignupRequest } from "@/types/auth.types"; type AuthContextValue = { @@ -12,6 +12,7 @@ type AuthContextValue = { isAuthenticated: boolean; login: (request: LoginRequest) => Promise; signup: (request: SignupRequest) => Promise; + signupAdmin: (request: SignupRequest) => Promise; logout: () => void; }; @@ -25,6 +26,7 @@ const AuthContext = createContext({ isAuthenticated: false, login: () => Promise.reject(new Error("AuthProvider is not mounted.")), signup: () => Promise.reject(new Error("AuthProvider is not mounted.")), + signupAdmin: () => Promise.reject(new Error("AuthProvider is not mounted.")), logout: () => {}, }); @@ -51,6 +53,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [persistSession], ); + const handleAdminSignup = useCallback( + async (request: SignupRequest) => persistSession(await signupAdmin(request)), + [persistSession], + ); + const logout = useCallback(() => { localStorage.removeItem(AUTH_TOKEN_KEY); document.cookie = `${AUTH_ROLE_COOKIE_KEY}=; path=/; max-age=0; SameSite=Lax`; @@ -90,9 +97,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { isAuthenticated: Boolean(accessToken && user), login: handleLogin, signup: handleSignup, + signupAdmin: handleAdminSignup, logout, }), - [accessToken, handleLogin, handleSignup, isInitialized, logout, user], + [accessToken, handleAdminSignup, handleLogin, handleSignup, isInitialized, logout, user], ); return {children}; diff --git a/festival-app/frontend/src/types/auth.types.ts b/festival-app/frontend/src/types/auth.types.ts index 025b6b8..c2f3a35 100644 --- a/festival-app/frontend/src/types/auth.types.ts +++ b/festival-app/frontend/src/types/auth.types.ts @@ -21,5 +21,4 @@ export type SignupRequest = { name: string; email: string; password: string; - role: AuthRole; }; diff --git a/festival-app/frontend/src/types/booth/booths.types.ts b/festival-app/frontend/src/types/booth/booths.types.ts index 0179315..0e6ed66 100644 --- a/festival-app/frontend/src/types/booth/booths.types.ts +++ b/festival-app/frontend/src/types/booth/booths.types.ts @@ -21,6 +21,7 @@ export type BoothReservationStatus = | "PENDING_APPROVAL" | "APPROVED" | "RESERVED" + | "QR_FAILED" | "CHECKED_IN" | "COMPLETED" | "CANCELLED"; @@ -42,6 +43,7 @@ export type BoothReservationApplication = { statusDescription: string; qrCode: string | null; sagaLogs: BoothReservationSagaLog[]; + compensationLogs: BoothReservationCompensationLog[]; createdAt: string; updatedAt: string; }; @@ -55,9 +57,18 @@ export type BoothReservationSagaLog = { export type BoothReservationApprovalRequest = { approverId: string; approverName: string; + simulateQrFailure?: boolean; }; -export type QrFailureReservationStatus = BoothReservationStatus | "QR_FAILED"; +export type BoothReservationCompensationLog = { + step: string; + reason: string; + fromStatus: BoothReservationStatus; + toStatus: BoothReservationStatus; + createdAt: string; +}; + +export type QrFailureReservationStatus = BoothReservationStatus; export type CompensationLog = { id: string;