diff --git a/backend/build.gradle b/backend/build.gradle index abb23ef03..39aff0e71 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.0.8' + id 'org.springframework.boot' version '3.1.4' id 'io.spring.dependency-management' version '1.1.0' } @@ -42,7 +42,7 @@ dependencies { implementation "com.github.maricn:logback-slack-appender:1.4.0" // Mockito - testImplementation 'org.mockito:mockito-inline' + testImplementation 'org.mockito:mockito-inline:5.2.0' // Cucumber testImplementation 'io.cucumber:cucumber-java:7.13.0' diff --git a/backend/src/main/java/com/festago/auth/annotation/Staff.java b/backend/src/main/java/com/festago/auth/annotation/Staff.java new file mode 100644 index 000000000..150e94dc0 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/annotation/Staff.java @@ -0,0 +1,12 @@ +package com.festago.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface Staff { + +} diff --git a/backend/src/main/java/com/festago/auth/application/StaffAuthService.java b/backend/src/main/java/com/festago/auth/application/StaffAuthService.java new file mode 100644 index 000000000..7084a6eff --- /dev/null +++ b/backend/src/main/java/com/festago/auth/application/StaffAuthService.java @@ -0,0 +1,38 @@ +package com.festago.auth.application; + +import com.festago.auth.domain.AuthPayload; +import com.festago.auth.domain.Role; +import com.festago.auth.dto.StaffLoginRequest; +import com.festago.auth.dto.StaffLoginResponse; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import com.festago.staff.domain.Staff; +import com.festago.staff.repository.StaffRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class StaffAuthService { + + private final AuthProvider authProvider; + private final StaffRepository staffRepository; + + @Transactional(readOnly = true) + public StaffLoginResponse login(StaffLoginRequest request) { + Staff staff = findStaffCode(request.code()); + String accessToken = authProvider.provide(createAuthPayload(staff)); + return new StaffLoginResponse(staff.getId(), accessToken); + } + + private Staff findStaffCode(String code) { + return staffRepository.findByCodeWithFetch(code) + .orElseThrow(() -> new UnauthorizedException(ErrorCode.INCORRECT_STAFF_CODE)); + } + + private AuthPayload createAuthPayload(Staff staff) { + return new AuthPayload(staff.getId(), Role.STAFF); + } +} diff --git a/backend/src/main/java/com/festago/auth/config/LoginConfig.java b/backend/src/main/java/com/festago/auth/config/LoginConfig.java index e340587a6..8fa2840dd 100644 --- a/backend/src/main/java/com/festago/auth/config/LoginConfig.java +++ b/backend/src/main/java/com/festago/auth/config/LoginConfig.java @@ -26,6 +26,7 @@ public class LoginConfig implements WebMvcConfigurer { public void addArgumentResolvers(List resolvers) { resolvers.add(new RoleArgumentResolver(Role.MEMBER, authenticateContext)); resolvers.add(new RoleArgumentResolver(Role.ADMIN, authenticateContext)); + resolvers.add(new RoleArgumentResolver(Role.STAFF, authenticateContext)); } @Override @@ -36,6 +37,9 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(memberAuthInterceptor()) .addPathPatterns("/member-tickets/**", "/members/**", "/auth/**", "/students/**") .excludePathPatterns("/auth/oauth2"); + registry.addInterceptor(staffAuthInterceptor()) + .addPathPatterns("/staff/**") + .excludePathPatterns("/staff/login"); } @Bean @@ -57,4 +61,14 @@ public AuthInterceptor memberAuthInterceptor() { .role(Role.MEMBER) .build(); } + + @Bean + public AuthInterceptor staffAuthInterceptor() { + return AuthInterceptor.builder() + .authExtractor(authExtractor) + .tokenExtractor(new HeaderTokenExtractor()) + .authenticateContext(authenticateContext) + .role(Role.STAFF) + .build(); + } } diff --git a/backend/src/main/java/com/festago/auth/domain/Role.java b/backend/src/main/java/com/festago/auth/domain/Role.java index 69936580b..607b9d585 100644 --- a/backend/src/main/java/com/festago/auth/domain/Role.java +++ b/backend/src/main/java/com/festago/auth/domain/Role.java @@ -3,6 +3,7 @@ import com.festago.auth.annotation.Admin; import com.festago.auth.annotation.Anonymous; import com.festago.auth.annotation.Member; +import com.festago.auth.annotation.Staff; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.InternalServerException; import java.lang.annotation.Annotation; @@ -11,6 +12,7 @@ public enum Role { ANONYMOUS(Anonymous.class), MEMBER(Member.class), ADMIN(Admin.class), + STAFF(Staff.class), ; private final Class annotation; diff --git a/backend/src/main/java/com/festago/auth/dto/StaffLoginRequest.java b/backend/src/main/java/com/festago/auth/dto/StaffLoginRequest.java new file mode 100644 index 000000000..7b5cc954c --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/StaffLoginRequest.java @@ -0,0 +1,10 @@ +package com.festago.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record StaffLoginRequest( + @NotBlank(message = "code는 공백일 수 없습니다.") + String code +) { + +} diff --git a/backend/src/main/java/com/festago/auth/dto/StaffLoginResponse.java b/backend/src/main/java/com/festago/auth/dto/StaffLoginResponse.java new file mode 100644 index 000000000..33f8494c8 --- /dev/null +++ b/backend/src/main/java/com/festago/auth/dto/StaffLoginResponse.java @@ -0,0 +1,8 @@ +package com.festago.auth.dto; + +public record StaffLoginResponse( + Long staffId, + String accessToken +) { + +} diff --git a/backend/src/main/java/com/festago/common/exception/ErrorCode.java b/backend/src/main/java/com/festago/common/exception/ErrorCode.java index d2e3b556f..b633935dd 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -25,6 +25,9 @@ public enum ErrorCode { DUPLICATE_STUDENT_EMAIL("이미 인증된 이메일입니다."), TICKET_CANNOT_RESERVE_STAGE_START("공연의 시작 시간 이후로 예매할 수 없습니다."), INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."), + DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), + STAFF_CODE_EXIST("이미 스태프 코드가 존재합니다."), + INVALID_SCHOOL_DOMAIN("올바르지 않은 학교 도메인입니다."), // 401 @@ -34,7 +37,7 @@ public enum ErrorCode { NEED_AUTH_TOKEN("로그인이 필요한 서비스입니다."), INCORRECT_PASSWORD_OR_ACCOUNT("비밀번호가 틀렸거나, 해당 계정이 없습니다."), DUPLICATE_ACCOUNT_USERNAME("해당 계정이 존재합니다."), - DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), + INCORRECT_STAFF_CODE("올바르지 않은 스태프 코드입니다."), // 403 NOT_ENOUGH_PERMISSION("해당 권한이 없습니다."), @@ -46,6 +49,7 @@ public enum ErrorCode { FESTIVAL_NOT_FOUND("존재하지 않는 축제입니다."), TICKET_NOT_FOUND("존재하지 않는 티켓입니다."), SCHOOL_NOT_FOUND("존재하지 않는 학교입니다."), + STAFF_NOT_FOUND("존재하지 않는 스태프입니다"), // 429 TOO_FREQUENT_REQUESTS("너무 잦은 요청입니다. 잠시 후 다시 시도해주세요."), @@ -63,7 +67,8 @@ public enum ErrorCode { INVALID_ROLE_NAME("해당하는 Role이 없습니다."), FOR_TEST_ERROR("테스트용 에러입니다."), FAIL_SEND_FCM_MESSAGE("FCM Message 전송에 실패했습니다."), - FCM_NOT_FOUND("유효하지 않은 MemberFCM 이 감지 되었습니다."); + FCM_NOT_FOUND("유효하지 않은 MemberFCM 이 감지 되었습니다."), + ; private final String message; diff --git a/backend/src/main/java/com/festago/entry/application/EntryService.java b/backend/src/main/java/com/festago/entry/application/EntryService.java index 061e369d8..b3ea70032 100644 --- a/backend/src/main/java/com/festago/entry/application/EntryService.java +++ b/backend/src/main/java/com/festago/entry/application/EntryService.java @@ -2,6 +2,7 @@ import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ForbiddenException; import com.festago.common.exception.NotFoundException; import com.festago.entry.domain.EntryCode; import com.festago.entry.domain.EntryCodePayload; @@ -9,6 +10,8 @@ import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; import com.festago.entry.dto.event.EntryProcessEvent; +import com.festago.staff.domain.Staff; +import com.festago.staff.repository.StaffRepository; import com.festago.ticketing.domain.MemberTicket; import com.festago.ticketing.repository.MemberTicketRepository; import java.time.Clock; @@ -25,6 +28,7 @@ public class EntryService { private final EntryCodeManager entryCodeManager; private final MemberTicketRepository memberTicketRepository; + private final StaffRepository staffRepository; private final ApplicationEventPublisher publisher; private final Clock clock; @@ -45,11 +49,23 @@ private MemberTicket findMemberTicket(Long memberTicketId) { .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_TICKET_NOT_FOUND)); } - public TicketValidationResponse validate(TicketValidationRequest request) { + public TicketValidationResponse validate(TicketValidationRequest request, Long staffId) { EntryCodePayload entryCodePayload = entryCodeManager.extract(request.code()); MemberTicket memberTicket = findMemberTicket(entryCodePayload.getMemberTicketId()); + checkPermission(findStaff(staffId), memberTicket); memberTicket.changeState(entryCodePayload.getEntryState()); publisher.publishEvent(new EntryProcessEvent(memberTicket.getOwner().getId())); return TicketValidationResponse.from(memberTicket); } + + private Staff findStaff(Long staffId) { + return staffRepository.findById(staffId) + .orElseThrow(() -> new NotFoundException(ErrorCode.STAFF_NOT_FOUND)); + } + + private void checkPermission(Staff staff, MemberTicket memberTicket) { + if (!staff.canValidate(memberTicket)) { + throw new ForbiddenException(ErrorCode.NOT_ENOUGH_PERMISSION); + } + } } diff --git a/backend/src/main/java/com/festago/festival/application/FestivalService.java b/backend/src/main/java/com/festago/festival/application/FestivalService.java index bcd5fe00d..8eed059f0 100644 --- a/backend/src/main/java/com/festago/festival/application/FestivalService.java +++ b/backend/src/main/java/com/festago/festival/application/FestivalService.java @@ -10,6 +10,7 @@ import com.festago.festival.dto.FestivalDetailResponse; import com.festago.festival.dto.FestivalResponse; import com.festago.festival.dto.FestivalsResponse; +import com.festago.festival.dto.event.FestivalCreateEvent; import com.festago.festival.repository.FestivalRepository; import com.festago.school.domain.School; import com.festago.school.repository.SchoolRepository; @@ -17,6 +18,7 @@ import com.festago.stage.repository.StageRepository; import java.time.LocalDate; import java.util.List; +import org.springframework.context.ApplicationEventPublisher; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +31,7 @@ public class FestivalService { private final FestivalRepository festivalRepository; private final StageRepository stageRepository; private final SchoolRepository schoolRepository; + private final ApplicationEventPublisher publisher; public FestivalResponse create(FestivalCreateRequest request) { School school = schoolRepository.findById(request.schoolId()) @@ -36,6 +39,7 @@ public FestivalResponse create(FestivalCreateRequest request) { Festival festival = request.toEntity(school); validate(festival); Festival newFestival = festivalRepository.save(festival); + publisher.publishEvent(new FestivalCreateEvent(newFestival.getId())); return FestivalResponse.from(newFestival); } diff --git a/backend/src/main/java/com/festago/festival/dto/event/FestivalCreateEvent.java b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreateEvent.java new file mode 100644 index 000000000..69a51e4b3 --- /dev/null +++ b/backend/src/main/java/com/festago/festival/dto/event/FestivalCreateEvent.java @@ -0,0 +1,7 @@ +package com.festago.festival.dto.event; + +public record FestivalCreateEvent( + Long festivalId +) { + +} diff --git a/backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java b/backend/src/main/java/com/festago/presentation/StaffController.java similarity index 53% rename from backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java rename to backend/src/main/java/com/festago/presentation/StaffController.java index 4c05c3e42..e1b994176 100644 --- a/backend/src/main/java/com/festago/presentation/StaffMemberTicketController.java +++ b/backend/src/main/java/com/festago/presentation/StaffController.java @@ -1,6 +1,9 @@ package com.festago.presentation; - +import com.festago.auth.annotation.Staff; +import com.festago.auth.application.StaffAuthService; +import com.festago.auth.dto.StaffLoginRequest; +import com.festago.auth.dto.StaffLoginResponse; import com.festago.entry.application.EntryService; import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; @@ -15,17 +18,28 @@ import org.springframework.web.bind.annotation.RestController; @RestController -@RequestMapping("/staff/member-tickets") +@RequestMapping("/staff") @Tag(name = "스태프 요청") @RequiredArgsConstructor -public class StaffMemberTicketController { +public class StaffController { + private final StaffAuthService staffAuthService; private final EntryService entryService; - @PostMapping("/validation") + @PostMapping("/login") + @Operation(description = "스태프 코드로 로그인한다.", summary = "스태프 로그인") + public ResponseEntity login(@RequestBody @Valid StaffLoginRequest request) { + StaffLoginResponse response = staffAuthService.login(request); + return ResponseEntity.ok() + .body(response); + } + + @PostMapping("/member-tickets/validation") @Operation(description = "스태프가 티켓을 검사한다.", summary = "티켓 검사") - public ResponseEntity validate(@RequestBody @Valid TicketValidationRequest request) { - TicketValidationResponse response = entryService.validate(request); + public ResponseEntity validate( + @RequestBody @Valid TicketValidationRequest request, + @Staff Long staffId) { + TicketValidationResponse response = entryService.validate(request, staffId); return ResponseEntity.ok() .body(response); } diff --git a/backend/src/main/java/com/festago/school/domain/School.java b/backend/src/main/java/com/festago/school/domain/School.java index 84534ddc5..9a67da0cc 100644 --- a/backend/src/main/java/com/festago/school/domain/School.java +++ b/backend/src/main/java/com/festago/school/domain/School.java @@ -1,6 +1,7 @@ package com.festago.school.domain; import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.exception.BadRequestException; import com.festago.common.exception.ErrorCode; import com.festago.common.exception.InternalServerException; import jakarta.persistence.Column; @@ -10,6 +11,7 @@ import jakarta.persistence.Id; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import java.util.regex.Pattern; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -17,6 +19,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class School extends BaseTimeEntity { + private static final Pattern DOMAIN_REGEX = Pattern.compile("^[^.]+(\\.[^.]+)+$"); + private static final char DOMAIN_DELIMITER = '.'; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -45,6 +50,7 @@ public School(Long id, String domain, String name) { private void validate(String domain, String name) { checkNotNull(domain, name); checkLength(domain, name); + validateDomain(domain); } private void checkNotNull(String domain, String name) { @@ -68,6 +74,17 @@ private boolean overLength(String target, int maxLength) { return target.length() > maxLength; } + private void validateDomain(String domain) { + if (!DOMAIN_REGEX.matcher(domain).matches()) { + throw new BadRequestException(ErrorCode.INVALID_SCHOOL_DOMAIN); + } + } + + public String findAbbreviation() { + int dotIndex = domain.indexOf(DOMAIN_DELIMITER); + return domain.substring(0, dotIndex); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/staff/application/RandomStaffCodeProvider.java b/backend/src/main/java/com/festago/staff/application/RandomStaffCodeProvider.java new file mode 100644 index 000000000..628aa3ac4 --- /dev/null +++ b/backend/src/main/java/com/festago/staff/application/RandomStaffCodeProvider.java @@ -0,0 +1,24 @@ +package com.festago.staff.application; + +import static java.util.stream.Collectors.joining; + +import com.festago.festival.domain.Festival; +import com.festago.staff.domain.StaffCode; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import org.springframework.stereotype.Component; + +@Component +public class RandomStaffCodeProvider implements StaffCodeProvider { + + @Override + public StaffCode provide(Festival festival) { + String abbreviation = festival.getSchool().findAbbreviation(); + Random random = ThreadLocalRandom.current(); + String code = random.ints(0, 10) + .mapToObj(String::valueOf) + .limit(StaffCode.RANDOM_CODE_LENGTH) + .collect(joining()); + return new StaffCode(abbreviation + code); + } +} diff --git a/backend/src/main/java/com/festago/staff/application/StaffCodeProvider.java b/backend/src/main/java/com/festago/staff/application/StaffCodeProvider.java new file mode 100644 index 000000000..a5df2410a --- /dev/null +++ b/backend/src/main/java/com/festago/staff/application/StaffCodeProvider.java @@ -0,0 +1,9 @@ +package com.festago.staff.application; + +import com.festago.festival.domain.Festival; +import com.festago.staff.domain.StaffCode; + +public interface StaffCodeProvider { + + StaffCode provide(Festival festival); +} diff --git a/backend/src/main/java/com/festago/staff/application/StaffEventListener.java b/backend/src/main/java/com/festago/staff/application/StaffEventListener.java new file mode 100644 index 000000000..74afa2b68 --- /dev/null +++ b/backend/src/main/java/com/festago/staff/application/StaffEventListener.java @@ -0,0 +1,18 @@ +package com.festago.staff.application; + +import com.festago.festival.dto.event.FestivalCreateEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StaffEventListener { + + private final StaffService staffService; + + @EventListener + public void createStaff(FestivalCreateEvent event) { + staffService.createStaff(event.festivalId()); + } +} diff --git a/backend/src/main/java/com/festago/staff/application/StaffService.java b/backend/src/main/java/com/festago/staff/application/StaffService.java new file mode 100644 index 000000000..b6d23b136 --- /dev/null +++ b/backend/src/main/java/com/festago/staff/application/StaffService.java @@ -0,0 +1,51 @@ +package com.festago.staff.application; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.staff.domain.Staff; +import com.festago.staff.domain.StaffCode; +import com.festago.staff.dto.StaffResponse; +import com.festago.staff.repository.StaffRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class StaffService { + + private final StaffRepository staffRepository; + private final FestivalRepository festivalRepository; + private final StaffCodeProvider codeProvider; + + public StaffResponse createStaff(Long festivalId) { + Festival festival = findFestival(festivalId); + if (staffRepository.existsByFestival(festival)) { + throw new BadRequestException(ErrorCode.STAFF_CODE_EXIST); + } + StaffCode code = createVerificationCode(festival); + + Staff staff = staffRepository.save(new Staff(code, festival)); + + return StaffResponse.from(staff); + } + + private StaffCode createVerificationCode(Festival festival) { + List existCodes = staffRepository.findAllCodeByCodeStartsWith(festival.getSchool().findAbbreviation()); + StaffCode code; + do { + code = codeProvider.provide(festival); + } while (existCodes.contains(code.getValue())); + return code; + } + + private Festival findFestival(Long festivalId) { + return festivalRepository.findById(festivalId) + .orElseThrow(() -> new NotFoundException(ErrorCode.FESTIVAL_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/staff/domain/Staff.java b/backend/src/main/java/com/festago/staff/domain/Staff.java new file mode 100644 index 000000000..74fa413eb --- /dev/null +++ b/backend/src/main/java/com/festago/staff/domain/Staff.java @@ -0,0 +1,65 @@ +package com.festago.staff.domain; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.festival.domain.Festival; +import com.festago.ticketing.domain.MemberTicket; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Staff { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + @NotNull + private StaffCode code; + + @OneToOne(fetch = FetchType.LAZY) + private Festival festival; + + public Staff(StaffCode code, Festival festival) { + this(null, code, festival); + } + + public Staff(Long id, StaffCode code, Festival festival) { + checkNotNull(code, festival); + this.id = id; + this.code = code; + this.festival = festival; + } + + private void checkNotNull(StaffCode code, Festival festival) { + if (code == null || festival == null) { + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + public boolean canValidate(MemberTicket memberTicket) { + return memberTicket.belongsToFestival(festival.getId()); + } + + public Long getId() { + return id; + } + + public StaffCode getCode() { + return code; + } + + public Festival getFestival() { + return festival; + } +} diff --git a/backend/src/main/java/com/festago/staff/domain/StaffCode.java b/backend/src/main/java/com/festago/staff/domain/StaffCode.java new file mode 100644 index 000000000..24c9176b0 --- /dev/null +++ b/backend/src/main/java/com/festago/staff/domain/StaffCode.java @@ -0,0 +1,35 @@ +package com.festago.staff.domain; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StaffCode { + + public static final int RANDOM_CODE_LENGTH = 4; + + @NotNull + @Column(name = "code") + private String value; + + public StaffCode(String value) { + checkNotNull(value); + this.value = value; + } + + private void checkNotNull(String value) { + if (value == null) { + throw new InternalServerException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + public String getValue() { + return value; + } +} diff --git a/backend/src/main/java/com/festago/staff/dto/StaffResponse.java b/backend/src/main/java/com/festago/staff/dto/StaffResponse.java new file mode 100644 index 000000000..20da37e9f --- /dev/null +++ b/backend/src/main/java/com/festago/staff/dto/StaffResponse.java @@ -0,0 +1,16 @@ +package com.festago.staff.dto; + +import com.festago.staff.domain.Staff; + +public record StaffResponse( + Long id, + String code +) { + + public static StaffResponse from(Staff staff) { + return new StaffResponse( + staff.getId(), + staff.getCode().getValue() + ); + } +} diff --git a/backend/src/main/java/com/festago/staff/repository/StaffRepository.java b/backend/src/main/java/com/festago/staff/repository/StaffRepository.java new file mode 100644 index 000000000..0c984d90f --- /dev/null +++ b/backend/src/main/java/com/festago/staff/repository/StaffRepository.java @@ -0,0 +1,29 @@ +package com.festago.staff.repository; + +import com.festago.festival.domain.Festival; +import com.festago.staff.domain.Staff; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface StaffRepository extends JpaRepository { + + boolean existsByFestival(Festival festival); + + @Query(""" + SELECT s + FROM Staff s + LEFT JOIN FETCH s.festival + WHERE s.code.value = :code + """) + Optional findByCodeWithFetch(@Param("code") String code); + + @Query(""" + SELECT s.code.value + FROM Staff s + WHERE s.code.value LIKE :prefix% + """) + List findAllCodeByCodeStartsWith(@Param("prefix") String prefix); +} diff --git a/backend/src/main/java/com/festago/stage/domain/Stage.java b/backend/src/main/java/com/festago/stage/domain/Stage.java index d15327e12..9e121df5f 100644 --- a/backend/src/main/java/com/festago/stage/domain/Stage.java +++ b/backend/src/main/java/com/festago/stage/domain/Stage.java @@ -17,6 +17,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -102,6 +103,10 @@ public boolean isStart(LocalDateTime currentTime) { return currentTime.isAfter(startTime); } + public boolean belongsToFestival(Long festivalId) { + return Objects.equals(festival.getId(), festivalId); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java b/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java index ec5f881f2..bba1b812e 100644 --- a/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java +++ b/backend/src/main/java/com/festago/ticketing/domain/MemberTicket.java @@ -113,6 +113,10 @@ public boolean canEntry(LocalDateTime currentTime) { && currentTime.isBefore(entryTime.plusHours(ENTRY_LIMIT_HOUR)); } + public boolean belongsToFestival(Long festivalId) { + return stage.belongsToFestival(festivalId); + } + public Long getId() { return id; } diff --git a/backend/src/main/resources/db/migration/V6__staff_code.sql b/backend/src/main/resources/db/migration/V6__staff_code.sql new file mode 100644 index 000000000..7da29179d --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__staff_code.sql @@ -0,0 +1,18 @@ +create table staff_code +( + id bigint not null auto_increment, + code varchar(255) not null, + festival_id bigint, + created_at datetime(6), + updated_at datetime(6), + primary key (id) +) engine innodb + default charset = utf8mb4 + collate = utf8mb4_0900_ai_ci; + + +alter table staff_code + add constraint fk_staff_code__festival + foreign key (festival_id) + references festival (id); + diff --git a/backend/src/test/java/com/festago/application/EntryServiceTest.java b/backend/src/test/java/com/festago/application/EntryServiceTest.java index 8786f8de6..7ef06eab1 100644 --- a/backend/src/test/java/com/festago/application/EntryServiceTest.java +++ b/backend/src/test/java/com/festago/application/EntryServiceTest.java @@ -1,9 +1,11 @@ package com.festago.application; import static com.festago.common.exception.ErrorCode.MEMBER_TICKET_NOT_FOUND; +import static com.festago.common.exception.ErrorCode.NOT_ENOUGH_PERMISSION; import static com.festago.common.exception.ErrorCode.NOT_ENTRY_TIME; import static com.festago.common.exception.ErrorCode.NOT_MEMBER_TICKET_OWNER; -import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static com.festago.common.exception.ErrorCode.STAFF_NOT_FOUND; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -13,6 +15,7 @@ import static org.mockito.Mockito.verify; import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ForbiddenException; import com.festago.common.exception.NotFoundException; import com.festago.entry.application.EntryCodeManager; import com.festago.entry.application.EntryService; @@ -24,10 +27,14 @@ import com.festago.entry.dto.event.EntryProcessEvent; import com.festago.festival.domain.Festival; import com.festago.member.domain.Member; +import com.festago.staff.domain.Staff; +import com.festago.staff.repository.StaffRepository; import com.festago.stage.domain.Stage; import com.festago.support.FestivalFixture; import com.festago.support.MemberFixture; import com.festago.support.MemberTicketFixture; +import com.festago.support.SetUpMockito; +import com.festago.support.StaffFixture; import com.festago.support.StageFixture; import com.festago.support.TimeInstantProvider; import com.festago.ticketing.domain.EntryState; @@ -39,6 +46,7 @@ import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; import org.junit.jupiter.api.Nested; @@ -61,6 +69,9 @@ class EntryServiceTest { @Mock MemberTicketRepository memberTicketRepository; + @Mock + StaffRepository staffRepository; + @Mock ApplicationEventPublisher publisher; @@ -214,20 +225,65 @@ class 티켓의_QR_생성_요청 { @Nested class 티켓_검사 { - @Test - void 예매한_티켓의_입장_상태와_요청의_입장_상태가_같으면_에매한_티켓의_입장_상태를_변경한다() { - // given - TicketValidationRequest request = new TicketValidationRequest("code"); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .id(1L) - .build(); - given(entryCodeManager.extract(anyString())) + TicketValidationRequest request; + Long staffId; + Long festivalId; + MemberTicket memberTicket; + + @BeforeEach + void setUp() { + request = new TicketValidationRequest("code"); + staffId = 1L; + festivalId = 2L; + Festival festival = FestivalFixture.festival().id(festivalId).build(); + Staff staff = StaffFixture.staff().id(staffId).festival(festival).build(); + Stage stage = StageFixture.stage().festival(festival).build(); + memberTicket = MemberTicketFixture.memberTicket().id(1L).stage(stage).build(); + + SetUpMockito + .given(staffRepository.findById(staffId)) + .willReturn(Optional.of(staff)); + SetUpMockito + .given(entryCodeManager.extract(anyString())) .willReturn(new EntryCodePayload(1L, EntryState.BEFORE_ENTRY)); - given(memberTicketRepository.findById(anyLong())) + SetUpMockito + .given(memberTicketRepository.findById(anyLong())) .willReturn(Optional.of(memberTicket)); + } + + @Test + void 스태프가_없으면_예외_발생() { + // given + given(staffRepository.findById(staffId)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> entryService.validate(request, staffId)) + .isInstanceOf(NotFoundException.class) + .hasMessage(STAFF_NOT_FOUND.getMessage()); + } + + @Test + void 해당_축제의_티켓이_아니면_권한없음_예외_발생() { + // given + festivalId = festivalId + 1L; + Festival festival = FestivalFixture.festival().id(festivalId).build(); + Staff staff = StaffFixture.staff().id(staffId).festival(festival).build(); + + given(staffRepository.findById(staffId)) + .willReturn(Optional.of(staff)); + + // when & then + assertThatThrownBy(() -> entryService.validate(request, staffId)) + .isInstanceOf(ForbiddenException.class) + .hasMessage(NOT_ENOUGH_PERMISSION.getMessage()); + } + + @Test + void 예매한_티켓의_입장_상태와_요청의_입장_상태가_같으면_에매한_티켓의_입장_상태를_변경한다() { // when - TicketValidationResponse expect = entryService.validate(request); + TicketValidationResponse expect = entryService.validate(request, staffId); // then assertSoftly(softly -> { @@ -239,17 +295,11 @@ class 티켓_검사 { @Test void 예매한_티켓의_입장_상태와_요청의_입장_상태가_다르면_에매한_티켓의_입장_상태를_변경하지_않는다() { // given - TicketValidationRequest request = new TicketValidationRequest("code"); - MemberTicket memberTicket = MemberTicketFixture.memberTicket() - .id(1L) - .build(); given(entryCodeManager.extract(anyString())) .willReturn(new EntryCodePayload(1L, EntryState.AFTER_ENTRY)); - given(memberTicketRepository.findById(anyLong())) - .willReturn(Optional.of(memberTicket)); // when - TicketValidationResponse expect = entryService.validate(request); + TicketValidationResponse expect = entryService.validate(request, staffId); // then assertSoftly(softly -> { diff --git a/backend/src/test/java/com/festago/application/FestivalServiceTest.java b/backend/src/test/java/com/festago/application/FestivalServiceTest.java index 3a1fb9e4f..8e744b3ab 100644 --- a/backend/src/test/java/com/festago/application/FestivalServiceTest.java +++ b/backend/src/test/java/com/festago/application/FestivalServiceTest.java @@ -38,6 +38,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; @ExtendWith(MockitoExtension.class) @DisplayNameGeneration(ReplaceUnderscores.class) @@ -53,6 +54,9 @@ class FestivalServiceTest { @Mock SchoolRepository schoolRepository; + @Mock + ApplicationEventPublisher publisher; + @InjectMocks FestivalService festivalService; diff --git a/backend/src/test/java/com/festago/auth/application/StaffAuthServiceTest.java b/backend/src/test/java/com/festago/auth/application/StaffAuthServiceTest.java new file mode 100644 index 000000000..33dfd7cb6 --- /dev/null +++ b/backend/src/test/java/com/festago/auth/application/StaffAuthServiceTest.java @@ -0,0 +1,91 @@ +package com.festago.auth.application; + +import static com.festago.common.exception.ErrorCode.INCORRECT_STAFF_CODE; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import com.festago.auth.domain.AuthPayload; +import com.festago.auth.dto.StaffLoginRequest; +import com.festago.auth.dto.StaffLoginResponse; +import com.festago.common.exception.UnauthorizedException; +import com.festago.staff.domain.Staff; +import com.festago.staff.repository.StaffRepository; +import com.festago.support.SetUpMockito; +import com.festago.support.StaffFixture; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class StaffAuthServiceTest { + + @Mock + AuthProvider authProvider; + + @Mock + StaffRepository staffRepository; + + @InjectMocks + StaffAuthService staffAuthService; + + @Nested + class 로그인 { + + StaffLoginRequest request; + String token; + Long staffId; + + @BeforeEach + void setUp() { + request = new StaffLoginRequest("festa1234"); + token = "staffToken"; + staffId = 1L; + + Staff staff = StaffFixture.staff().id(staffId).build(); + + SetUpMockito + .given(staffRepository.findByCodeWithFetch(anyString())) + .willReturn(Optional.of(staff)); + + SetUpMockito + .given(authProvider.provide(any(AuthPayload.class))) + .willReturn(token); + } + + @Test + void 일치하는_코드가_없으면_예외() { + // given + given(staffRepository.findByCodeWithFetch(anyString())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> staffAuthService.login(request)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(INCORRECT_STAFF_CODE.getMessage()); + } + + @Test + void 성공() { + // when + StaffLoginResponse response = staffAuthService.login(request); + + // then + assertSoftly(softly -> { + softly.assertThat(response.accessToken()).isEqualTo(token); + softly.assertThat(response.staffId()).isEqualTo(staffId); + }); + } + } +} diff --git a/backend/src/test/java/com/festago/domain/StageTest.java b/backend/src/test/java/com/festago/domain/StageTest.java index 850410a2b..bcc5b2181 100644 --- a/backend/src/test/java/com/festago/domain/StageTest.java +++ b/backend/src/test/java/com/festago/domain/StageTest.java @@ -2,16 +2,20 @@ import static com.festago.common.exception.ErrorCode.INVALID_STAGE_START_TIME; import static com.festago.common.exception.ErrorCode.INVALID_TICKET_OPEN_TIME; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.festago.common.exception.BadRequestException; import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; import com.festago.support.FestivalFixture; import com.festago.support.StageFixture; import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -101,4 +105,35 @@ class StageTest { .build()); } + @Nested + class 해당_축제의_무대인지_확인 { + + Long festivalId; + Stage stage; + + @BeforeEach + void setUp() { + festivalId = 1L; + Festival festival = FestivalFixture.festival().id(festivalId).build(); + stage = StageFixture.stage().festival(festival).build(); + } + + @Test + void 참() { + // when + boolean actual = stage.belongsToFestival(festivalId); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 거짓() { + // when + boolean actual = stage.belongsToFestival(festivalId + 1L); + + // then + assertThat(actual).isFalse(); + } + } } diff --git a/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java b/backend/src/test/java/com/festago/presentation/StaffControllerTest.java similarity index 61% rename from backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java rename to backend/src/test/java/com/festago/presentation/StaffControllerTest.java index 0bcdd1a26..8a665f194 100644 --- a/backend/src/test/java/com/festago/presentation/StaffMemberTicketControllerTest.java +++ b/backend/src/test/java/com/festago/presentation/StaffControllerTest.java @@ -1,16 +1,22 @@ package com.festago.presentation; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import com.festago.auth.application.StaffAuthService; +import com.festago.auth.domain.Role; +import com.festago.auth.dto.StaffLoginRequest; +import com.festago.auth.dto.StaffLoginResponse; import com.festago.entry.application.EntryService; import com.festago.entry.dto.TicketValidationRequest; import com.festago.entry.dto.TicketValidationResponse; import com.festago.support.CustomWebMvcTest; +import com.festago.support.WithMockAuth; import com.festago.ticketing.domain.EntryState; import java.nio.charset.StandardCharsets; import org.junit.jupiter.api.DisplayNameGeneration; @@ -21,11 +27,10 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; - -@CustomWebMvcTest(StaffMemberTicketController.class) @DisplayNameGeneration(ReplaceUnderscores.class) @SuppressWarnings("NonAsciiCharacters") -class StaffMemberTicketControllerTest { +@CustomWebMvcTest(StaffController.class) +class StaffControllerTest { @Autowired MockMvc mockMvc; @@ -33,19 +38,45 @@ class StaffMemberTicketControllerTest { @Autowired ObjectMapper objectMapper; + @MockBean + StaffAuthService staffAuthService; + @MockBean EntryService entryService; @Test + void 스태프_로그인() throws Exception { + // given + StaffLoginResponse expected = new StaffLoginResponse(1L, "token"); + given(staffAuthService.login(any(StaffLoginRequest.class))) + .willReturn(expected); + StaffLoginRequest request = new StaffLoginRequest("festago1234"); + + // when & then + String content = mockMvc.perform(post("/staff/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(StandardCharsets.UTF_8); + StaffLoginResponse actual = objectMapper.readValue(content, StaffLoginResponse.class); + assertThat(actual).isEqualTo(expected); + } + + @Test + @WithMockAuth(role = Role.STAFF) void QR을_검사한다() throws Exception { // given TicketValidationRequest request = new TicketValidationRequest("anyCode"); TicketValidationResponse expected = new TicketValidationResponse(EntryState.AFTER_ENTRY); - given(entryService.validate(request)) + given(entryService.validate(request, 1L)) .willReturn(expected); // when & then String content = mockMvc.perform(post("/staff/member-tickets/validation") + .header("Authorization", "Bearer token") .content(objectMapper.writeValueAsString(request)) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) diff --git a/backend/src/test/java/com/festago/school/domain/SchoolTest.java b/backend/src/test/java/com/festago/school/domain/SchoolTest.java new file mode 100644 index 000000000..49c514e1e --- /dev/null +++ b/backend/src/test/java/com/festago/school/domain/SchoolTest.java @@ -0,0 +1,101 @@ +package com.festago.school.domain; + +import static com.festago.common.exception.ErrorCode.INTERNAL_SERVER_ERROR; +import static com.festago.common.exception.ErrorCode.INVALID_SCHOOL_DOMAIN; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.InternalServerException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class SchoolTest { + + @Nested + class domain_검증 { + + @ValueSource(strings = {"", "a", ".a", "a.", "a..a"}) + @ParameterizedTest + void 올바르지_않은_domain이면_예외(String domain) { + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교")) + .isInstanceOf(BadRequestException.class) + .hasMessage(INVALID_SCHOOL_DOMAIN.getMessage()); + } + + @ValueSource(strings = {"festa.com", "festa.go.com"}) + @ParameterizedTest + void 올바른_domain이면_성공(String domain) { + // when & then + assertThatNoException() + .isThrownBy(() -> new School(domain, "테코대학교")); + } + } + + @Nested + class null_체크 { + + @Test + void domain이_null이면_예외() { + // when & then + assertThatThrownBy(() -> new School(null, "테코대학교")) + .isInstanceOf(InternalServerException.class) + .hasMessage(INTERNAL_SERVER_ERROR.getMessage()); + } + + @Test + void name이_null이면_예외() { + // when & then + assertThatThrownBy(() -> new School("festa.com", null)) + .isInstanceOf(InternalServerException.class) + .hasMessage(INTERNAL_SERVER_ERROR.getMessage()); + } + } + + @Nested + class 길이_검증 { + + @Test + void domain이_50자보다_길면_예외() { + // given + String domain = "a".repeat(49) + ".b"; + + // when & then + assertThatThrownBy(() -> new School(domain, "테코대학교")) + .isInstanceOf(InternalServerException.class) + .hasMessage(INTERNAL_SERVER_ERROR.getMessage()); + } + + @Test + void name이_255자보다_길면_예외() { + // given + String name = "a".repeat(256); + + // when & then + assertThatThrownBy(() -> new School("festa.com", name)) + .isInstanceOf(InternalServerException.class) + .hasMessage(INTERNAL_SERVER_ERROR.getMessage()); + } + } + + + @Test + void 약어_조회() { + // given + School school = new School("festa.ac.kr", "테코대학교"); + + // when + String abbreviation = school.findAbbreviation(); + + // then + assertThat(abbreviation).isEqualTo("festa"); + } +} diff --git a/backend/src/test/java/com/festago/staff/application/RandomStaffCodeProviderTest.java b/backend/src/test/java/com/festago/staff/application/RandomStaffCodeProviderTest.java new file mode 100644 index 000000000..43469ab40 --- /dev/null +++ b/backend/src/test/java/com/festago/staff/application/RandomStaffCodeProviderTest.java @@ -0,0 +1,36 @@ +package com.festago.staff.application; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.staff.domain.StaffCode; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class RandomStaffCodeProviderTest { + + RandomStaffCodeProvider codeProvider = new RandomStaffCodeProvider(); + + @Test + void 생성() { + // given + String abbreviation = "festa"; + School school = SchoolFixture.school().domain(abbreviation + ".ac.kr").build(); + Festival festival = FestivalFixture.festival().school(school).build(); + + // when + StaffCode code = codeProvider.provide(festival); + + // then + assertSoftly(softly -> { + softly.assertThat(code.getValue()).startsWith(abbreviation); + softly.assertThat(code.getValue()).hasSize(abbreviation.length() + 4); + }); + } +} diff --git a/backend/src/test/java/com/festago/staff/application/StaffServiceTest.java b/backend/src/test/java/com/festago/staff/application/StaffServiceTest.java new file mode 100644 index 000000000..a196ad8fd --- /dev/null +++ b/backend/src/test/java/com/festago/staff/application/StaffServiceTest.java @@ -0,0 +1,124 @@ +package com.festago.staff.application; + +import static com.festago.common.exception.ErrorCode.FESTIVAL_NOT_FOUND; +import static com.festago.common.exception.ErrorCode.STAFF_CODE_EXIST; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.staff.domain.Staff; +import com.festago.staff.domain.StaffCode; +import com.festago.staff.dto.StaffResponse; +import com.festago.staff.repository.StaffRepository; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import com.festago.support.SetUpMockito; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@ExtendWith(MockitoExtension.class) +class StaffServiceTest { + + @Spy + StaffCodeProvider codeProvider; + + @Mock + StaffRepository staffRepository; + + @Mock + FestivalRepository festivalRepository; + + @InjectMocks + StaffService staffService; + + + @Nested + class 스태프_코드_생성 { + + @BeforeEach + void setUp() { + School school = SchoolFixture.school().build(); + Festival festival = FestivalFixture.festival().school(school).build(); + + SetUpMockito + .given(festivalRepository.findById(anyLong())) + .willReturn(Optional.of(festival)); + + SetUpMockito + .given(staffRepository.existsByFestival(any(Festival.class))) + .willReturn(false); + + SetUpMockito + .given(staffRepository.findAllCodeByCodeStartsWith(anyString())) + .willReturn(List.of("festa1234", "festa2345", "festa3456")); + + SetUpMockito + .given(staffRepository.save(any(Staff.class))) + .willAnswer(invocation -> { + Staff staff = invocation.getArgument(0); + return new Staff(1L, staff.getCode(), staff.getFestival()); + }); + + } + + @Test + void 축제가_없으면_예외() { + // given + given(festivalRepository.findById(anyLong())) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> staffService.createStaff(1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(FESTIVAL_NOT_FOUND.getMessage()); + } + + @Test + void 이미_스태프코드가_존재하면_예외() { + // given + given(staffRepository.existsByFestival(any(Festival.class))) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> staffService.createStaff(1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(STAFF_CODE_EXIST.getMessage()); + } + + @Test + void staffCode가_중복되지_않을때까지_반복한다() { + // given + String firstCode = "festa1234"; + String secondCode = "festa5678"; + given(codeProvider.provide(any(Festival.class))) + .willReturn(new StaffCode(firstCode)) + .willReturn(new StaffCode(secondCode)); + + // when + StaffResponse response = staffService.createStaff(1L); + + // then + assertThat(response.code()).isEqualTo(secondCode); + } + } +} diff --git a/backend/src/test/java/com/festago/staff/domain/StaffCodeTest.java b/backend/src/test/java/com/festago/staff/domain/StaffCodeTest.java new file mode 100644 index 000000000..79d4c2a7d --- /dev/null +++ b/backend/src/test/java/com/festago/staff/domain/StaffCodeTest.java @@ -0,0 +1,30 @@ +package com.festago.staff.domain; + +import static com.festago.common.exception.ErrorCode.INTERNAL_SERVER_ERROR; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.InternalServerException; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StaffCodeTest { + + @Test + void null이면_예외() { + // when & then + assertThatThrownBy(() -> new StaffCode(null)) + .isInstanceOf(InternalServerException.class) + .hasMessage(INTERNAL_SERVER_ERROR.getMessage()); + } + + @Test + void 생성() { + // when & then + assertThatNoException() + .isThrownBy(() -> new StaffCode("festa1234")); + } +} diff --git a/backend/src/test/java/com/festago/staff/domain/StaffTest.java b/backend/src/test/java/com/festago/staff/domain/StaffTest.java new file mode 100644 index 000000000..05de7f97e --- /dev/null +++ b/backend/src/test/java/com/festago/staff/domain/StaffTest.java @@ -0,0 +1,60 @@ +package com.festago.staff.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.stage.domain.Stage; +import com.festago.support.FestivalFixture; +import com.festago.support.MemberTicketFixture; +import com.festago.support.StaffFixture; +import com.festago.support.StageFixture; +import com.festago.ticketing.domain.MemberTicket; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StaffTest { + + @Nested + class 티켓_검사_권한_확인 { + + MemberTicket memberTicket; + Festival festival; + + @BeforeEach + void setUp() { + festival = FestivalFixture.festival().id(1L).build(); + Stage stage = StageFixture.stage().festival(festival).build(); + memberTicket = MemberTicketFixture.memberTicket().stage(stage).build(); + } + + @Test + void 같은_축제이면_참() { + // given + Staff staff = StaffFixture.staff().festival(festival).build(); + + // when + boolean result = staff.canValidate(memberTicket); + + // then + assertThat(result).isTrue(); + } + + @Test + void 다른_축제면_거짓() { + // given + Festival otherFestival = FestivalFixture.festival().id(2L).build(); + Staff staff = StaffFixture.staff().festival(otherFestival).build(); + + // when + boolean result = staff.canValidate(memberTicket); + + // then + assertThat(result).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/festago/staff/repository/StaffRepositoryTest.java b/backend/src/test/java/com/festago/staff/repository/StaffRepositoryTest.java new file mode 100644 index 000000000..a24790d63 --- /dev/null +++ b/backend/src/test/java/com/festago/staff/repository/StaffRepositoryTest.java @@ -0,0 +1,116 @@ +package com.festago.staff.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.staff.domain.Staff; +import com.festago.support.FestivalFixture; +import com.festago.support.SchoolFixture; +import com.festago.support.StaffFixture; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; + +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +@DataJpaTest +class StaffRepositoryTest { + + @Autowired + StaffRepository staffRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Nested + class 축제로_존재여부_확인 { + + @Test + void 있으면_참() { + // given + Festival festival = saveFestival(); + staffRepository.save(StaffFixture.staff().codeValue("festa1234").festival(festival).build()); + + // when + boolean result = staffRepository.existsByFestival(festival); + + // then + assertThat(result).isTrue(); + } + + @Test + void 없으면_거짓() { + // given + Festival festival = saveFestival(); + + // when + boolean result = staffRepository.existsByFestival(festival); + + // then + assertThat(result).isFalse(); + } + } + + @Test + void code로_조회() { + // given + String code = "festa1234"; + Festival festival = saveFestival(); + Staff saved = staffRepository.save( + StaffFixture.staff().codeValue(code).festival(festival).build()); + + // when + Optional result = staffRepository.findByCodeWithFetch(code); + + // then + assertSoftly(softly -> { + softly.assertThat(result).isNotEmpty(); + softly.assertThat(result.get().getId()).isEqualTo(saved.getId()); + softly.assertThat(result.get().getFestival()).isEqualTo(festival); + }); + } + + @Test + void 코드_프리픽스로_조회() { + // given + Festival festival1 = saveFestival(1); + Festival festival2 = saveFestival(2); + Festival festival3 = saveFestival(3); + Festival festival4 = saveFestival(4); + staffRepository.save(StaffFixture.staff().codeValue("festa1234").festival(festival1).build()); + staffRepository.save(StaffFixture.staff().codeValue("festa2345").festival(festival2).build()); + staffRepository.save(StaffFixture.staff().codeValue("festa3456").festival(festival3).build()); + staffRepository.save(StaffFixture.staff().codeValue("go3456").festival(festival4).build()); + + // when + List result = staffRepository.findAllCodeByCodeStartsWith("festa"); + + // then + assertThat(result) + .containsExactlyInAnyOrder("festa1234", "festa2345", "festa3456"); + } + + private Festival saveFestival() { + return saveFestival(0); + } + + private Festival saveFestival(int number) { + School school = schoolRepository.save(SchoolFixture.school() + .name("페스타고 대학교" + number) + .domain("festago" + number + ".com") + .build()); + return festivalRepository.save(FestivalFixture.festival().school(school).build()); + } +} diff --git a/backend/src/test/java/com/festago/support/SetUpMockito.java b/backend/src/test/java/com/festago/support/SetUpMockito.java index 98357391d..4c642e3f6 100644 --- a/backend/src/test/java/com/festago/support/SetUpMockito.java +++ b/backend/src/test/java/com/festago/support/SetUpMockito.java @@ -1,6 +1,7 @@ package com.festago.support; import org.mockito.Mockito; +import org.mockito.stubbing.Answer; import org.mockito.stubbing.OngoingStubbing; public class SetUpMockito { @@ -21,5 +22,9 @@ public Given(OngoingStubbing ongoingStubbing) { public void willReturn(T value) { ongoingStubbing.thenReturn(value); } + + public void willAnswer(Answer answer) { + ongoingStubbing.thenAnswer(answer); + } } } diff --git a/backend/src/test/java/com/festago/support/StaffFixture.java b/backend/src/test/java/com/festago/support/StaffFixture.java new file mode 100644 index 000000000..29774af24 --- /dev/null +++ b/backend/src/test/java/com/festago/support/StaffFixture.java @@ -0,0 +1,43 @@ +package com.festago.support; + +import com.festago.festival.domain.Festival; +import com.festago.staff.domain.Staff; +import com.festago.staff.domain.StaffCode; + +public class StaffFixture { + + private Long id; + private StaffCode code = new StaffCode("festa1234"); + private Festival festival = FestivalFixture.festival().build(); + + private StaffFixture() { + } + + public static StaffFixture staff() { + return new StaffFixture(); + } + + public StaffFixture id(Long id) { + this.id = id; + return this; + } + + public StaffFixture codeValue(String code) { + this.code = new StaffCode(code); + return this; + } + + public StaffFixture code(StaffCode code) { + this.code = code; + return this; + } + + public StaffFixture festival(Festival festival) { + this.festival = festival; + return this; + } + + public Staff build() { + return new Staff(id, code, festival); + } +}