From 95038693f597df111b2b86a5e187909c1c6fa0a7 Mon Sep 17 00:00:00 2001 From: seokjin8678 Date: Tue, 11 Jun 2024 19:21:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=83=88=EB=A1=9C=EC=9A=B4=20Ticket=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../festago/common/exception/ErrorCode.java | 8 +- .../java/com/festago/stage/domain/Stage.java | 18 +- .../com/festago/ticket/domain/NewTicket.java | 106 ++++++ .../festago/ticket/domain/NewTicketType.java | 11 + .../festago/ticket/domain/StageTicket.java | 117 +++++++ .../ticket/domain/StageTicketEntryTime.java | 52 +++ .../ticket/domain/StageTicketEntryTimes.java | 52 +++ .../ticket/domain/TicketExclusive.java | 7 + .../com/festago/ticketing/domain/Booker.java | 30 ++ .../ticketing/domain/ReserveTicket.java | 117 +++++++ .../db/migration/V7__add_new_ticketing.sql | 53 +++ .../support/AbstractMemoryRepository.java | 9 +- .../support/fixture/ReserveTicketFixture.java | 56 ++++ .../fixture/StageTicketEntryTimeFixture.java | 43 +++ .../support/fixture/StageTicketFixture.java | 50 +++ .../domain/StageTicketEntryTimesTest.java | 120 +++++++ .../ticket/domain/StageTicketTest.java | 314 ++++++++++++++++++ .../ticketing/domain/ReserveTicketTest.java | 209 ++++++++++++ 18 files changed, 1361 insertions(+), 11 deletions(-) create mode 100644 backend/src/main/java/com/festago/ticket/domain/NewTicket.java create mode 100644 backend/src/main/java/com/festago/ticket/domain/NewTicketType.java create mode 100644 backend/src/main/java/com/festago/ticket/domain/StageTicket.java create mode 100644 backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTime.java create mode 100644 backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTimes.java create mode 100644 backend/src/main/java/com/festago/ticket/domain/TicketExclusive.java create mode 100644 backend/src/main/java/com/festago/ticketing/domain/Booker.java create mode 100644 backend/src/main/java/com/festago/ticketing/domain/ReserveTicket.java create mode 100644 backend/src/main/resources/db/migration/V7__add_new_ticketing.sql create mode 100644 backend/src/test/java/com/festago/support/fixture/ReserveTicketFixture.java create mode 100644 backend/src/test/java/com/festago/support/fixture/StageTicketEntryTimeFixture.java create mode 100644 backend/src/test/java/com/festago/support/fixture/StageTicketFixture.java create mode 100644 backend/src/test/java/com/festago/ticket/domain/StageTicketEntryTimesTest.java create mode 100644 backend/src/test/java/com/festago/ticket/domain/StageTicketTest.java create mode 100644 backend/src/test/java/com/festago/ticketing/domain/ReserveTicketTest.java 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 146d5d174..95ecb7100 100644 --- a/backend/src/main/java/com/festago/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/festago/common/exception/ErrorCode.java @@ -25,7 +25,6 @@ public enum ErrorCode { TICKET_CANNOT_RESERVE_STAGE_START("공연의 시작 시간 이후로 예매할 수 없습니다."), INVALID_STUDENT_VERIFICATION_CODE("올바르지 않은 학생 인증 코드입니다."), DELETE_CONSTRAINT_FESTIVAL("공연이 등록된 축제는 삭제할 수 없습니다."), - DELETE_CONSTRAINT_STAGE("티켓이 등록된 공연은 삭제할 수 없습니다."), DELETE_CONSTRAINT_SCHOOL("학생 또는 축제에 등록된 학교는 삭제할 수 없습니다."), // @deprecate DUPLICATE_SCHOOL("이미 존재하는 학교 정보입니다."), // @deprecate VALIDATION_FAIL("검증이 실패하였습니다."), @@ -47,6 +46,12 @@ public enum ErrorCode { OPEN_ID_INVALID_TOKEN("잘못된 OpenID 토큰입니다."), NOT_SUPPORT_FILE_EXTENSION("해당 파일의 확장자는 허용되지 않습니다."), DUPLICATE_ARTIST_NAME("이미 존재하는 아티스트의 이름입니다."), + RESERVE_TICKET_BEFORE_TICKET_OPEN_TIME("티켓 예매 시간 이전에는 예매 할 수 없습니다."), + RESERVE_TICKET_NOT_SCHOOL_STUDENT("해당 티켓의 예매는 소속된 학교를 다니는 재학생만 가능합니다."), + STAGE_UPDATE_CONSTRAINT_EXISTS_TICKET("티켓이 등록된 공연은 수정할 수 없습니다."), + STAGE_DELETE_CONSTRAINT_EXISTS_TICKET("티켓이 등록된 공연은 삭제할 수 없습니다."), + STAGE_TICKET_DELETE_CONSTRAINT_TICKET_OPEN_TIME("티켓 오픈 시간 이후에는 티켓을 삭제할 수 없습니다."), + ONLY_STAGE_TICKETING_SINGLE_TYPE("공연 당 하나의 유형의 티켓에 대해서만 예매가 가능합니다."), // 401 EXPIRED_AUTH_TOKEN("만료된 로그인 토큰입니다."), @@ -84,6 +89,7 @@ public enum ErrorCode { OAUTH2_INVALID_REQUEST("알 수 없는 OAuth2 에러가 발생했습니다."), OPEN_ID_PROVIDER_NOT_RESPONSE("OpenID 제공자 서버에 문제가 발생했습니다."), FILE_UPLOAD_ERROR("파일 업로드 중 에러가 발생했습니다."), + REDIS_ERROR("Redis에 문제가 발생했습니다."), ; private final String message; 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 514583325..939937aef 100644 --- a/backend/src/main/java/com/festago/stage/domain/Stage.java +++ b/backend/src/main/java/com/festago/stage/domain/Stage.java @@ -5,7 +5,6 @@ import com.festago.common.exception.ErrorCode; import com.festago.common.util.Validator; import com.festago.festival.domain.Festival; -import com.festago.ticket.domain.Ticket; import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -18,6 +17,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; import lombok.AccessLevel; @@ -41,9 +41,6 @@ public class Stage extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) private Festival festival; - @OneToMany(mappedBy = "stage", fetch = FetchType.LAZY) - private List tickets = new ArrayList<>(); - @OneToMany(fetch = FetchType.LAZY, mappedBy = "stageId", orphanRemoval = true, cascade = {CascadeType.PERSIST, CascadeType.REMOVE}) private List artists = new ArrayList<>(); @@ -85,6 +82,10 @@ public boolean isStart(LocalDateTime currentTime) { return currentTime.isAfter(startTime); } + public boolean isBeforeTicketOpenTime(LocalDateTime currentTime) { + return currentTime.isBefore(ticketOpenTime); + } + public void changeTime(LocalDateTime startTime, LocalDateTime ticketOpenTime) { validateTime(startTime, ticketOpenTime, this.festival); this.startTime = startTime; @@ -110,6 +111,11 @@ public List getArtistIds() { .toList(); } + // 디미터 법칙에 어긋나지만, n+1을 회피하고, fetch join을 생략하며 주인을 검사하기 위해 getter 체이닝 사용 + public boolean isSchoolStage(Long schoolId) { + return Objects.equals(getFestival().getSchool().getId(), schoolId); + } + public Long getId() { return id; } @@ -125,8 +131,4 @@ public LocalDateTime getTicketOpenTime() { public Festival getFestival() { return festival; } - - public List getTickets() { - return tickets; - } } diff --git a/backend/src/main/java/com/festago/ticket/domain/NewTicket.java b/backend/src/main/java/com/festago/ticket/domain/NewTicket.java new file mode 100644 index 000000000..7c1aaad06 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/NewTicket.java @@ -0,0 +1,106 @@ +package com.festago.ticket.domain; + +import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.Validator; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.ReserveTicket; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; +import jakarta.persistence.InheritanceType; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +// TODO NewTicket -> Ticket 이름 변경할 것 +@Entity +@Inheritance(strategy = InheritanceType.JOINED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class NewTicket extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + protected Long id; + + protected Long schoolId; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + protected TicketExclusive ticketExclusive; + + protected int amount = 0; + + /** + * 사용자가 최대 예매할 수 있는 티켓의 개수 + */ + protected int maxReserveAmount = 1; + + protected NewTicket(Long id, Long schoolId, TicketExclusive ticketExclusive) { + Validator.notNull(schoolId, "schoolId"); + Validator.notNull(ticketExclusive, "ticketExclusive"); + this.id = id; + this.schoolId = schoolId; + this.ticketExclusive = ticketExclusive; + } + + protected void changeAmount(int amount) { + Validator.notNegative(amount, "amount"); + this.amount = amount; + } + + public boolean isStudentOnly() { + return ticketExclusive == TicketExclusive.STUDENT; + } + + public boolean isSchoolStudent(Booker booker) { + return Objects.equals(this.schoolId, booker.getSchoolId()); + } + + public void changeMaxReserveAmount(int maxReserveAmount) { + Validator.minValue(maxReserveAmount, 1, "maxReserveAmount"); + this.maxReserveAmount = maxReserveAmount; + } + + public abstract void validateReserve(Booker booker, LocalDateTime currentTime); + + /** + * 티켓을 예매한다. 해당 메서드를 호출하기 전 반드시 validateReserve() 메서드를 호출해야 한다.
반환된 ReserveTicket은 영속되지 않았으므로, 반드시 영속시켜야 한다. + * + * @param booker 예매할 사용자 + * @param sequence 예매할 티켓의 순번 + * @return 영속되지 않은 상태의 ReserveTicket + */ + public abstract ReserveTicket reserve(Booker booker, int sequence); + + public abstract LocalDateTime getTicketingEndTime(); + + public boolean isEmptyAmount() { + return amount <= 0; + } + + public Long getId() { + return id; + } + + public Long getSchoolId() { + return schoolId; + } + + public TicketExclusive getTicketExclusive() { + return ticketExclusive; + } + + public int getAmount() { + return amount; + } + + public int getMaxReserveAmount() { + return maxReserveAmount; + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/NewTicketType.java b/backend/src/main/java/com/festago/ticket/domain/NewTicketType.java new file mode 100644 index 000000000..fd4058f39 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/NewTicketType.java @@ -0,0 +1,11 @@ +package com.festago.ticket.domain; + +// TODO NewTicket -> Ticket 이름 변경할 것 + +/** + * NewTicket의 구현체의 DiscriminatorValue 어노테이션의 속성의 이름과 반드시 똑같이 할 것! + */ +public enum NewTicketType { + STAGE, + ; +} diff --git a/backend/src/main/java/com/festago/ticket/domain/StageTicket.java b/backend/src/main/java/com/festago/ticket/domain/StageTicket.java new file mode 100644 index 000000000..92d325032 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/StageTicket.java @@ -0,0 +1,117 @@ +package com.festago.ticket.domain; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import com.festago.common.util.Validator; +import com.festago.stage.domain.Stage; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.ReserveTicket; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ManyToOne; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@DiscriminatorValue("STAGE") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageTicket extends NewTicket { + + private static final int EARLY_ENTRY_LIMIT = 12; + + @ManyToOne(fetch = FetchType.LAZY) + private Stage stage; + + @Embedded + private StageTicketEntryTimes ticketEntryTimes = new StageTicketEntryTimes(); + + public StageTicket(Long schoolId, TicketExclusive ticketType, Stage stage) { + this(null, schoolId, ticketType, stage); + } + + public StageTicket(Long id, Long schoolId, TicketExclusive ticketType, Stage stage) { + super(id, schoolId, ticketType); + validate(schoolId, stage); + this.stage = stage; + } + + private void validate(Long schoolId, Stage stage) { + Validator.notNull(stage, "stage"); + if (!stage.isSchoolStage(schoolId)) { + throw new UnauthorizedException(ErrorCode.NOT_ENOUGH_PERMISSION); + } + } + + @Override + public LocalDateTime getTicketingEndTime() { + return stage.getStartTime(); + } + + @Override + public void validateReserve(Booker booker, LocalDateTime currentTime) { + if (isStudentOnly() && !isSchoolStudent(booker)) { + throw new BadRequestException(ErrorCode.RESERVE_TICKET_NOT_SCHOOL_STUDENT); + } + if (stage.isStart(currentTime)) { + throw new BadRequestException(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START); + } + if (stage.isBeforeTicketOpenTime(currentTime)) { + throw new BadRequestException(ErrorCode.RESERVE_TICKET_BEFORE_TICKET_OPEN_TIME); + } + } + + @Override + public ReserveTicket reserve(Booker booker, int sequence) { + LocalDateTime entryTime = ticketEntryTimes.calculateEntryTime(sequence); + return new ReserveTicket(booker.getMemberId(), NewTicketType.STAGE, id, sequence, entryTime); + } + + public void addTicketEntryTime(Long schoolId, LocalDateTime currentTime, LocalDateTime entryTime, int amount) { + validateSchoolOwner(schoolId); + validateEntryTime(currentTime, entryTime); + ticketEntryTimes.add(new StageTicketEntryTime(id, entryTime, amount)); + changeAmount(ticketEntryTimes.getTotalAmount()); + } + + private void validateSchoolOwner(Long schoolId) { + if (!Objects.equals(this.schoolId, schoolId)) { + throw new UnauthorizedException(ErrorCode.NOT_ENOUGH_PERMISSION); + } + } + + private void validateEntryTime(LocalDateTime currentTime, LocalDateTime entryTime) { + if (!stage.isBeforeTicketOpenTime(currentTime)) { + throw new BadRequestException(ErrorCode.INVALID_TICKET_CREATE_TIME); + } + if (stage.isBeforeTicketOpenTime(entryTime)) { + throw new BadRequestException(ErrorCode.EARLY_TICKET_ENTRY_THAN_OPEN); + } + if (stage.isStart(entryTime)) { + throw new BadRequestException(ErrorCode.LATE_TICKET_ENTRY_TIME); + } + if (!stage.isStart(entryTime.plusHours(EARLY_ENTRY_LIMIT))) { + throw new BadRequestException(ErrorCode.EARLY_TICKET_ENTRY_TIME); + } + } + + public boolean deleteTicketEntryTime(Long schoolId, LocalDateTime currentTime, LocalDateTime entryTime) { + validateSchoolOwner(schoolId); + if (!stage.isBeforeTicketOpenTime(currentTime)) { + throw new BadRequestException(ErrorCode.STAGE_TICKET_DELETE_CONSTRAINT_TICKET_OPEN_TIME); + } + boolean isDeleted = ticketEntryTimes.remove(entryTime); + if (isDeleted) { + changeAmount(ticketEntryTimes.getTotalAmount()); + } + return isDeleted; + } + + public Stage getStage() { + return stage; + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTime.java b/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTime.java new file mode 100644 index 000000000..bf5b6763e --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTime.java @@ -0,0 +1,52 @@ +package com.festago.ticket.domain; + +import com.festago.common.domain.BaseTimeEntity; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StageTicketEntryTime extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long stageTicketId; + + private LocalDateTime entryTime; + + private int amount; + + public StageTicketEntryTime(Long stageTicketId, LocalDateTime entryTime, int amount) { + this(null, stageTicketId, entryTime, amount); + } + + public StageTicketEntryTime(Long id, Long stageTicketId, LocalDateTime entryTime, int amount) { + this.id = id; + this.stageTicketId = stageTicketId; + this.entryTime = entryTime; + this.amount = amount; + } + + public Long getId() { + return id; + } + + public Long getStageTicketId() { + return stageTicketId; + } + + public LocalDateTime getEntryTime() { + return entryTime; + } + + public int getAmount() { + return amount; + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTimes.java b/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTimes.java new file mode 100644 index 000000000..f60b35eba --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/StageTicketEntryTimes.java @@ -0,0 +1,52 @@ +package com.festago.ticket.domain; + +import static java.util.Comparator.comparing; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.util.Validator; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Embeddable; +import jakarta.persistence.OneToMany; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +@Embeddable +public class StageTicketEntryTimes { + + @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true, mappedBy = "stageTicketId") + private List ticketEntryTimes = new ArrayList<>(); + + public LocalDateTime calculateEntryTime(int sequence) { + Validator.minValue(sequence, 1, "sequence"); + int lastSequence = 0; + ticketEntryTimes.sort(comparing(StageTicketEntryTime::getEntryTime)); + for (StageTicketEntryTime ticketEntryTime : ticketEntryTimes) { + lastSequence += ticketEntryTime.getAmount(); + if (sequence <= lastSequence) { + return ticketEntryTime.getEntryTime(); + } + } + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + + public void add(StageTicketEntryTime stageTicketEntryTime) { + ticketEntryTimes.add(stageTicketEntryTime); + } + + public int getTotalAmount() { + return ticketEntryTimes.stream() + .mapToInt(StageTicketEntryTime::getAmount) + .sum(); + } + + public boolean remove(LocalDateTime entryTime) { + return ticketEntryTimes.removeIf(it -> Objects.equals(it.getEntryTime(), entryTime)); + } + + public boolean isEmpty() { + return ticketEntryTimes.isEmpty(); + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/TicketExclusive.java b/backend/src/main/java/com/festago/ticket/domain/TicketExclusive.java new file mode 100644 index 000000000..aea84164c --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/TicketExclusive.java @@ -0,0 +1,7 @@ +package com.festago.ticket.domain; + +public enum TicketExclusive { + STUDENT, + NONE, + ; +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/Booker.java b/backend/src/main/java/com/festago/ticketing/domain/Booker.java new file mode 100644 index 000000000..2f2c9d448 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/Booker.java @@ -0,0 +1,30 @@ +package com.festago.ticketing.domain; + +import com.festago.common.util.Validator; +import jakarta.annotation.Nullable; +import lombok.Builder; + +/** + * 티켓팅을 하는 사용자 + */ +@Builder +public class Booker { + + private final Long memberId; + private final Long schoolId; + + public Booker(Long memberId, Long schoolId) { + Validator.notNull(memberId, "memberId"); + this.memberId = memberId; + this.schoolId = schoolId; + } + + public Long getMemberId() { + return memberId; + } + + @Nullable + public Long getSchoolId() { + return schoolId; + } +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/ReserveTicket.java b/backend/src/main/java/com/festago/ticketing/domain/ReserveTicket.java new file mode 100644 index 000000000..689dea563 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/ReserveTicket.java @@ -0,0 +1,117 @@ +package com.festago.ticketing.domain; + +import com.festago.common.domain.BaseTimeEntity; +import com.festago.common.util.Validator; +import com.festago.ticket.domain.NewTicketType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import java.util.Objects; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReserveTicket extends BaseTimeEntity { + + private static final long ENTRY_LIMIT_HOUR = 24; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private EntryState entryState = EntryState.BEFORE_ENTRY; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar") + private NewTicketType ticketType; + + private Long ticketId; + + private int sequence; + + private LocalDateTime entryTime; + + public ReserveTicket(Long memberId, NewTicketType ticketType, Long ticketId, int sequence, + LocalDateTime entryTime) { + this(null, memberId, ticketType, ticketId, sequence, entryTime); + } + + public ReserveTicket(Long id, Long memberId, NewTicketType ticketType, Long ticketId, int sequence, + LocalDateTime entryTime) { + Validator.notNull(memberId, "memberId"); + Validator.notNull(ticketId, "ticketId"); + Validator.minValue(sequence, 1, "sequence"); + Validator.notNull(entryTime, "entryTime"); + Validator.notNull(ticketType, "ticketType"); + this.id = id; + this.memberId = memberId; + this.ticketType = ticketType; + this.ticketId = ticketId; + this.sequence = sequence; + this.entryTime = entryTime; + } + + public void changeState(EntryState originState) { + if (originState != this.entryState) { + return; + } + this.entryState = findNextState(originState); + } + + private EntryState findNextState(EntryState entryState) { + if (entryState == EntryState.AFTER_ENTRY) { + return EntryState.AWAY; + } + return EntryState.AFTER_ENTRY; + } + + public boolean isOwner(Long memberId) { + return Objects.equals(this.memberId, memberId); + } + + public boolean canEntry(LocalDateTime currentTime) { + return !isBeforeEntry(currentTime) && currentTime.isBefore(entryTime.plusHours(ENTRY_LIMIT_HOUR)); + } + + public boolean isBeforeEntry(LocalDateTime currentTime) { + return currentTime.isBefore(entryTime); + } + + public Long getId() { + return id; + } + + public EntryState getEntryState() { + return entryState; + } + + public Long getMemberId() { + return memberId; + } + + public Long getTicketId() { + return ticketId; + } + + public int getSequence() { + return sequence; + } + + public LocalDateTime getEntryTime() { + return entryTime; + } + + public NewTicketType getTicketType() { + return ticketType; + } +} diff --git a/backend/src/main/resources/db/migration/V7__add_new_ticketing.sql b/backend/src/main/resources/db/migration/V7__add_new_ticketing.sql new file mode 100644 index 000000000..0725f9ea9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__add_new_ticketing.sql @@ -0,0 +1,53 @@ +create table if not exists new_ticket +( + id bigint auto_increment primary key, + dtype varchar(10) not null, + school_id bigint not null, + ticket_exclusive varchar(10) not null, + amount int not null, + max_reserve_amount int not null, + created_at datetime(6) not null, + updated_at datetime(6) not null +); + +create table if not exists stage_ticket +( + id bigint primary key, + stage_id bigint not null, + constraint fk_stage_ticket__new_ticket + foreign key (id) references new_ticket (id), + constraint fk_stage_ticket__stage + foreign key (stage_id) references stage (id) +); + +create table if not exists reserve_ticket +( + id bigint auto_increment primary key, + member_id bigint not null, + ticket_id bigint not null, + sequence int not null, + entry_time datetime(6) not null, + entry_state varchar(15) not null, + ticket_type varchar(10) not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + constraint fk_reserve_ticket__member + foreign key (member_id) references member (id), + constraint fk_reserve_ticket__new_ticket + foreign key (ticket_id) references new_ticket (id) +); + +create index index_reserve_ticket_member_id_ticket_id + on reserve_ticket (member_id, ticket_id); + +create table if not exists stage_ticket_entry_time +( + id bigint auto_increment primary key, + stage_ticket_id bigint not null, + entry_time datetime(6) not null, + amount int not null, + created_at datetime(6) not null, + updated_at datetime(6) not null, + constraint fk_stage_ticket_entry_time__stage_ticket + foreign key (stage_ticket_id) references stage_ticket (id) +); diff --git a/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java b/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java index f55b7ae14..687749580 100644 --- a/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java +++ b/backend/src/test/java/com/festago/support/AbstractMemoryRepository.java @@ -1,6 +1,7 @@ package com.festago.support; import jakarta.persistence.Id; +import jakarta.persistence.Inheritance; import java.lang.reflect.Field; import java.util.HashMap; import java.util.Optional; @@ -14,8 +15,12 @@ public abstract class AbstractMemoryRepository { @SneakyThrows final public T save(T entity) { - Field[] fields = entity.getClass() - .getDeclaredFields(); + Class clazz = entity.getClass(); + Class superclass = clazz.getSuperclass(); + if (superclass.isAnnotationPresent(Inheritance.class)) { + clazz = superclass; + } + Field[] fields = clazz.getDeclaredFields(); for (Field field : fields) { if (field.isAnnotationPresent(Id.class)) { field.setAccessible(true); diff --git a/backend/src/test/java/com/festago/support/fixture/ReserveTicketFixture.java b/backend/src/test/java/com/festago/support/fixture/ReserveTicketFixture.java new file mode 100644 index 000000000..152833561 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/ReserveTicketFixture.java @@ -0,0 +1,56 @@ +package com.festago.support.fixture; + +import com.festago.ticket.domain.NewTicketType; +import com.festago.ticketing.domain.ReserveTicket; +import java.time.LocalDateTime; + +public class ReserveTicketFixture extends BaseFixture { + + private Long id; + private Long memberId = 1L; + private Long ticketId = 1L; + private int sequence = 1; + private LocalDateTime entryTime = LocalDateTime.now(); + private NewTicketType ticketType = NewTicketType.STAGE; + + private ReserveTicketFixture() { + } + + public static ReserveTicketFixture builder() { + return new ReserveTicketFixture(); + } + + public ReserveTicketFixture id(Long id) { + this.id = id; + return this; + } + + public ReserveTicketFixture memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public ReserveTicketFixture ticketId(Long ticketId) { + this.ticketId = ticketId; + return this; + } + + public ReserveTicketFixture sequence(int sequence) { + this.sequence = sequence; + return this; + } + + public ReserveTicketFixture entryTime(LocalDateTime entryTime) { + this.entryTime = entryTime; + return this; + } + + public ReserveTicketFixture ticketType(NewTicketType ticketType) { + this.ticketType = ticketType; + return this; + } + + public ReserveTicket build() { + return new ReserveTicket(id, memberId, ticketType, ticketId, sequence, entryTime); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/StageTicketEntryTimeFixture.java b/backend/src/test/java/com/festago/support/fixture/StageTicketEntryTimeFixture.java new file mode 100644 index 000000000..883864a99 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StageTicketEntryTimeFixture.java @@ -0,0 +1,43 @@ +package com.festago.support.fixture; + +import com.festago.ticket.domain.StageTicketEntryTime; +import java.time.LocalDateTime; + +public class StageTicketEntryTimeFixture extends BaseFixture { + + private Long id; + private Long stageTicketId; + private LocalDateTime entryTime; + private int amount; + + public StageTicketEntryTimeFixture() { + } + + public static StageTicketEntryTimeFixture builder() { + return new StageTicketEntryTimeFixture(); + } + + public StageTicketEntryTimeFixture id(Long id) { + this.id = id; + return this; + } + + public StageTicketEntryTimeFixture stageTicketId(Long stageTicketId) { + this.stageTicketId = stageTicketId; + return this; + } + + public StageTicketEntryTimeFixture entryTime(LocalDateTime entryTime) { + this.entryTime = entryTime; + return this; + } + + public StageTicketEntryTimeFixture amount(int amount) { + this.amount = amount; + return this; + } + + public StageTicketEntryTime build() { + return new StageTicketEntryTime(id, stageTicketId, entryTime, amount); + } +} diff --git a/backend/src/test/java/com/festago/support/fixture/StageTicketFixture.java b/backend/src/test/java/com/festago/support/fixture/StageTicketFixture.java new file mode 100644 index 000000000..63b7512e5 --- /dev/null +++ b/backend/src/test/java/com/festago/support/fixture/StageTicketFixture.java @@ -0,0 +1,50 @@ +package com.festago.support.fixture; + +import com.festago.stage.domain.Stage; +import com.festago.ticket.domain.StageTicket; +import com.festago.ticket.domain.TicketExclusive; + +public class StageTicketFixture extends BaseFixture { + + private Long id; + + private Long schoolId; + + private TicketExclusive ticketExclusive = TicketExclusive.NONE; + + private Stage stage = StageFixture.builder().build(); + + private StageTicketFixture() { + } + + public static StageTicketFixture builder() { + return new StageTicketFixture(); + } + + public StageTicketFixture id(Long id) { + this.id = id; + return this; + } + + public StageTicketFixture schoolId(Long schoolId) { + this.schoolId = schoolId; + return this; + } + + public StageTicketFixture ticketExclusive(TicketExclusive ticketExclusive) { + this.ticketExclusive = ticketExclusive; + return this; + } + + public StageTicketFixture stage(Stage stage) { + this.stage = stage; + return this; + } + + public StageTicket build() { + if (schoolId == null) { + schoolId = stage.getFestival().getSchool().getId(); + } + return new StageTicket(id, schoolId, ticketExclusive, stage); + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/StageTicketEntryTimesTest.java b/backend/src/test/java/com/festago/ticket/domain/StageTicketEntryTimesTest.java new file mode 100644 index 000000000..2f7c94d20 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/StageTicketEntryTimesTest.java @@ -0,0 +1,120 @@ +package com.festago.ticket.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.ValidException; +import com.festago.support.fixture.StageTicketEntryTimeFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageTicketEntryTimesTest { + + @Nested + class calculateEntryTime { + + /** + * 입장 시간 별 10개의 티켓이 있다. + */ + LocalDateTime 첫번째_입장_시간 = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime 두번째_입장_시간 = LocalDateTime.parse("2077-06-30T19:00:00"); + LocalDateTime 세번째_입장_시간 = LocalDateTime.parse("2077-06-30T20:00:00"); + + StageTicketEntryTimes stageTicketEntryTimes; + + @BeforeEach + void setUp() { + stageTicketEntryTimes = new StageTicketEntryTimes(); + stageTicketEntryTimes.add(StageTicketEntryTimeFixture.builder() + .amount(10) + .entryTime(첫번째_입장_시간) + .build()); + stageTicketEntryTimes.add(StageTicketEntryTimeFixture.builder() + .amount(10) + .entryTime(두번째_입장_시간) + .build()); + stageTicketEntryTimes.add(StageTicketEntryTimeFixture.builder() + .amount(10) + .entryTime(세번째_입장_시간) + .build()); + } + + @ValueSource(ints = {1, 10}) + @ParameterizedTest + void 첫번째_입장_시간에_해당하는_순번은_첫번째_입장_시간을_반환한다(int sequence) { + // when + LocalDateTime entryTime = stageTicketEntryTimes.calculateEntryTime(sequence); + + // then + assertThat(entryTime).isEqualTo(첫번째_입장_시간); + } + + @ValueSource(ints = {11, 20}) + @ParameterizedTest + void 두번째_입장_시간에_해당하는_순번은_두번째_입장_시간을_반환한다(int sequence) { + // when + LocalDateTime entryTime = stageTicketEntryTimes.calculateEntryTime(sequence); + + // then + assertThat(entryTime).isEqualTo(두번째_입장_시간); + } + + @ValueSource(ints = {21, 30}) + @ParameterizedTest + void 세번째_입장_시간에_해당하는_순번은_세번째_입장_시간을_반환한다(int sequence) { + // when + LocalDateTime entryTime = stageTicketEntryTimes.calculateEntryTime(sequence); + + // then + assertThat(entryTime).isEqualTo(세번째_입장_시간); + } + + @ValueSource(ints = {0, -1}) + @ParameterizedTest + void 순번이_0_이하이면_예외(int sequence) { + // when & then + assertThatThrownBy(() -> stageTicketEntryTimes.calculateEntryTime(sequence)) + .isInstanceOf(ValidException.class); + } + + @Test + void 순번이_입장_시간의_티켓_수를_초과하면_예외() { + // given + int sequence = 31; + + // when & then + assertThatThrownBy(() -> stageTicketEntryTimes.calculateEntryTime(sequence)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TICKET_SOLD_OUT.getMessage()); + } + + @ValueSource(ints = {1, 10, 11, 20}) + @ParameterizedTest + void 입장_시간이_동일한_경우_같은_입장_시간을_반환한다(int sequence) { + // given + stageTicketEntryTimes = new StageTicketEntryTimes(); + stageTicketEntryTimes.add(StageTicketEntryTimeFixture.builder() + .amount(10) + .entryTime(첫번째_입장_시간) + .build()); + stageTicketEntryTimes.add(StageTicketEntryTimeFixture.builder() + .amount(10) + .entryTime(첫번째_입장_시간) + .build()); + + // when + assertThat(stageTicketEntryTimes.calculateEntryTime(sequence)) + .isEqualTo(첫번째_입장_시간); + } + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/StageTicketTest.java b/backend/src/test/java/com/festago/ticket/domain/StageTicketTest.java new file mode 100644 index 000000000..9b1e59faa --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/StageTicketTest.java @@ -0,0 +1,314 @@ +package com.festago.ticket.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.ErrorCode; +import com.festago.common.exception.UnauthorizedException; +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticketing.domain.Booker; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +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(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageTicketTest { + + LocalDateTime _6월_12일_17시_0분 = LocalDateTime.parse("2077-06-12T17:00:00"); + LocalDateTime _6월_12일_18시_0분 = LocalDateTime.parse("2077-06-12T18:00:00"); + LocalDateTime _6월_15일_17시_0분 = LocalDateTime.parse("2077-06-15T17:00:00"); + LocalDateTime _6월_15일_18시_0분 = LocalDateTime.parse("2077-06-15T18:00:00"); + Long schoolId = 1L; + + @Nested + class 생성자 { + + @Test + void 생성하려는_티켓의_학교_식별자와_공연의_식별자가_다르면_예외() { + // given + School school = SchoolFixture.builder().id(schoolId).build(); + Festival festival = FestivalFixture.builder() + .school(school) + .startDate(_6월_15일_18시_0분.toLocalDate()) + .endDate(_6월_15일_18시_0분.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .festival(festival) + .startTime(_6월_15일_18시_0분) + .ticketOpenTime(_6월_15일_17시_0분) + .build(); + + assertThatThrownBy(() -> new StageTicket(4885L, TicketExclusive.NONE, stage)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.NOT_ENOUGH_PERMISSION.getMessage()); + } + } + + @Nested + class addTicketEntryTime { + + @Test + void 파라미터의_학교_식별자와_티켓의_학교_식별자가_다르면_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when & then + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + assertThatThrownBy(() -> stageTicket.addTicketEntryTime(4885L, now, entryTime, 100)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.NOT_ENOUGH_PERMISSION.getMessage()); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1, 2}) + void 티켓_오픈_시간_이후에_티켓_입장_시간을_추가하면_예외(long second) { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when & then + LocalDateTime now = _6월_12일_18시_0분.plusSeconds(second); + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + Long schoolId = stageTicket.getSchoolId(); + assertThatThrownBy(() -> stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.INVALID_TICKET_CREATE_TIME.getMessage()); + } + + @ParameterizedTest + @ValueSource(longs = {1, 2}) + void 입장_시간이_티켓_오픈_시간_이전이면_예외(long second) { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_15일_17시_0분, TicketExclusive.NONE); + + // when & then + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_12일_17시_0분.minusSeconds(second); + + assertThatThrownBy(() -> stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EARLY_TICKET_ENTRY_THAN_OPEN.getMessage()); + } + + @ParameterizedTest + @ValueSource(longs = {1, 2}) + void 입장_시간이_공연_시작_시간_이후이면_예외(long second) { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when & then + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.plusSeconds(second); + assertThatThrownBy(() -> stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.LATE_TICKET_ENTRY_TIME.getMessage()); + } + + @ParameterizedTest + @ValueSource(longs = {0, 1, 2}) + void 입장_시간이_공연_시작_시간보다_12시간_더_빠르면_예외(long second) { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when & then + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(12).minusSeconds(second); + assertThatThrownBy(() -> stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.EARLY_TICKET_ENTRY_TIME.getMessage()); + } + + @Test + void 티켓_입장_시간을_추가하면_티켓_수량이_추가된다() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100); + + // then + assertThat(stageTicket.getAmount()).isEqualTo(100); + } + } + + @Nested + class validateReserve { + + @Test + void 티켓이_재학생_전용이고_예매자가_학생이_아닌_경우_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.STUDENT); + stageTicket.addTicketEntryTime(schoolId, _6월_12일_17시_0분, _6월_15일_17시_0분, 100); + Booker booker = Booker.builder().memberId(schoolId).build(); + + // when + assertThatThrownBy(() -> stageTicket.validateReserve(booker, _6월_12일_18시_0분)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.RESERVE_TICKET_NOT_SCHOOL_STUDENT.getMessage()); + } + + @Test + void 티켓이_재학생_전용이고_예매자가_해당_학교의_학생이_아닌_경우_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.STUDENT); + stageTicket.addTicketEntryTime(schoolId, _6월_12일_17시_0분, _6월_15일_17시_0분, 100); + Booker booker = Booker.builder().memberId(schoolId).schoolId(4885L).build(); + + // when + assertThatThrownBy(() -> stageTicket.validateReserve(booker, _6월_12일_18시_0분)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.RESERVE_TICKET_NOT_SCHOOL_STUDENT.getMessage()); + } + + @Test + void 티켓의_공연이_시작_이후이면_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_17시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + stageTicket.addTicketEntryTime(schoolId, _6월_12일_17시_0분, _6월_15일_17시_0분, 100); + Booker booker = Booker.builder().memberId(schoolId).schoolId(4885L).build(); + + // when + assertThatThrownBy(() -> stageTicket.validateReserve(booker, _6월_15일_18시_0분)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.TICKET_CANNOT_RESERVE_STAGE_START.getMessage()); + } + + @Test + void 티켓_예매_시간_이전에_예매를_시도하면_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_17시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + stageTicket.addTicketEntryTime(schoolId, _6월_12일_17시_0분, _6월_15일_17시_0분, 100); + Booker booker = Booker.builder().memberId(schoolId).schoolId(4885L).build(); + + // when + assertThatThrownBy(() -> stageTicket.validateReserve(booker, _6월_12일_17시_0분)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.RESERVE_TICKET_BEFORE_TICKET_OPEN_TIME.getMessage()); + } + + @Test + void 조건에_이상이_없으면_예외가_발생하지_않는다() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_17시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + stageTicket.addTicketEntryTime(schoolId, _6월_12일_17시_0분, _6월_15일_17시_0분, 100); + Booker booker = Booker.builder().memberId(schoolId).schoolId(4885L).build(); + + // when + assertThatNoException().isThrownBy(() -> stageTicket.validateReserve(booker, _6월_15일_17시_0분)); + } + } + + @Nested + class getTicketingEndTime { + + @Test + void 티켓팅이_종료되는_시간은_공연의_시작_시간이다() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_17시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + + // when + LocalDateTime ticketingEndTime = stageTicket.getTicketingEndTime(); + + // then + Stage stage = stageTicket.getStage(); + assertThat(ticketingEndTime).isEqualTo(stage.getStartTime()); + } + } + + @Nested + class deleteTicketEntryTime { + + @Test + void 파라미터의_학교_식별자와_티켓의_학교_식별자가_다르면_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + + // when & then + assertThatThrownBy(() -> stageTicket.deleteTicketEntryTime(4885L, now, entryTime)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorCode.NOT_ENOUGH_PERMISSION.getMessage()); + } + + @Test + void 티켓_오픈_시간_이후에_삭제하면_예외() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + LocalDateTime now = stageTicket.getStage().getTicketOpenTime().plusSeconds(1); + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + + // when & then + assertThatThrownBy(() -> stageTicket.deleteTicketEntryTime(schoolId, now, entryTime)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.STAGE_TICKET_DELETE_CONSTRAINT_TICKET_OPEN_TIME.getMessage()); + } + + @Test + void 삭제하려는_입장_시간이_존재하지_않으면_거짓이_반환된다() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100); + + // when + boolean actual = stageTicket.deleteTicketEntryTime(schoolId, now, entryTime.minusSeconds(1)); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 삭제하려는_입장_시간이_존재하면_참이_반환되고_수량이_반영된다() { + // given + StageTicket stageTicket = createStageTicket(_6월_15일_18시_0분, _6월_12일_18시_0분, TicketExclusive.NONE); + LocalDateTime now = _6월_12일_17시_0분; + LocalDateTime entryTime = _6월_15일_18시_0분.minusHours(1); + stageTicket.addTicketEntryTime(schoolId, now, entryTime, 100); + stageTicket.addTicketEntryTime(schoolId, now, entryTime.plusSeconds(1), 50); + + // when + boolean actual = stageTicket.deleteTicketEntryTime(schoolId, now, entryTime); + + // then + assertThat(actual).isTrue(); + assertThat(stageTicket.getAmount()).isEqualTo(50); + } + } + + StageTicket createStageTicket( + LocalDateTime stageStartTime, + LocalDateTime stageTicketOpenTime, + TicketExclusive ticketExclusive + ) { + School school = SchoolFixture.builder().id(schoolId).build(); + Festival festival = FestivalFixture.builder() + .school(school) + .startDate(stageStartTime.toLocalDate()) + .endDate(stageStartTime.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .festival(festival) + .startTime(stageStartTime) + .ticketOpenTime(stageTicketOpenTime) + .build(); + return StageTicketFixture.builder().id(1L).schoolId(school.getId()).stage(stage) + .ticketExclusive(ticketExclusive).build(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/domain/ReserveTicketTest.java b/backend/src/test/java/com/festago/ticketing/domain/ReserveTicketTest.java new file mode 100644 index 000000000..fe58ec241 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/domain/ReserveTicketTest.java @@ -0,0 +1,209 @@ +package com.festago.ticketing.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.support.fixture.ReserveTicketFixture; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ReserveTicketTest { + + @Nested + class changeState { + + @ParameterizedTest + @EnumSource(mode = EnumSource.Mode.EXCLUDE, names = "BEFORE_ENTRY") + void 입장_상태를_변경할때_원본_상태와_다르면_변경되지_않는다(EntryState originState) { + // given + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).build(); + + // when + reserveTicket.changeState(originState); + + // then + assertThat(reserveTicket.getEntryState()).isEqualTo(EntryState.BEFORE_ENTRY); + } + + @Test + void 입장_상태를_변경할때_원본_상태와_같으면_변경된다() { + // given + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).build(); + + // when + reserveTicket.changeState(EntryState.BEFORE_ENTRY); + + // then + assertThat(reserveTicket.getEntryState()).isEqualTo(EntryState.AFTER_ENTRY); + } + + @Test + void 입장_상태가_AFTER_ENTRY_일때_다음_상태는_AWAY_이다() { + // given + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).build(); + reserveTicket.changeState(EntryState.BEFORE_ENTRY); + + // when + reserveTicket.changeState(EntryState.AFTER_ENTRY); + + // then + assertThat(reserveTicket.getEntryState()).isEqualTo(EntryState.AWAY); + } + + @Test + void 입장_상태가_AWAY_일때_다음_상태는_AFTER_ENTRY_이다() { + // given + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).build(); + reserveTicket.changeState(EntryState.BEFORE_ENTRY); + reserveTicket.changeState(EntryState.AFTER_ENTRY); + + // when + reserveTicket.changeState(EntryState.AWAY); + + // then + assertThat(reserveTicket.getEntryState()).isEqualTo(EntryState.AFTER_ENTRY); + } + } + + @Nested + class isOwner { + + @Test + void 티켓의_주인과_다르면_거짓() { + // given + Long memberId = 1L; + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).memberId(4885L).build(); + + // when + boolean actual = reserveTicket.isOwner(memberId); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 티켓의_주인과_일치하면_참() { + // given + Long memberId = 4885L; + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).memberId(4885L).build(); + + // when + boolean actual = reserveTicket.isOwner(memberId); + + // then + assertThat(actual).isTrue(); + } + } + + @Nested + class isBeforeEntry { + + @Test + void 입장_시간_이전이면_참() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T17:59:59"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.isBeforeEntry(now); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 입장_시간과_같으면_거짓() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.isBeforeEntry(now); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 입장_시간_이후이면_거짓() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:01"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.isBeforeEntry(now); + + // then + assertThat(actual).isFalse(); + } + } + + @Nested + class canEntry { + + @Test + void 입장_시간_이전이면_거짓() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T17:59:59"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.canEntry(now); + + // then + assertThat(actual).isFalse(); + } + + @Test + void 입장_시간과_같으면_참() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.canEntry(now); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 입장_시간_이후이면_참() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:01"); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.canEntry(now); + + // then + assertThat(actual).isTrue(); + } + + @Test + void 입장_시간_이후_24시간을_초과하면_거짓() { + // given + LocalDateTime now = LocalDateTime.parse("2077-06-30T18:00:00").plusHours(24); + LocalDateTime entryTime = LocalDateTime.parse("2077-06-30T18:00:00"); + ReserveTicket reserveTicket = ReserveTicketFixture.builder().id(1L).entryTime(entryTime).build(); + + // when + boolean actual = reserveTicket.canEntry(now); + + // then + assertThat(actual).isFalse(); + } + } +}