diff --git a/reservation/build.gradle b/reservation/build.gradle index dfc0e2b..994d204 100644 --- a/reservation/build.gradle +++ b/reservation/build.gradle @@ -65,6 +65,12 @@ dependencies { // === Kafka 의존성 === implementation 'org.springframework.kafka:spring-kafka' testImplementation 'org.springframework.kafka:spring-kafka-test' + + // === Spring Validation 의존성 === + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // === ArchUnit 아키텍처 테스트 의존성 === + testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1' } tasks.named('test') { diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/ProgramService.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/ProgramService.java new file mode 100644 index 0000000..ef89e49 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/ProgramService.java @@ -0,0 +1,47 @@ +package net.catsnap.CatsnapReservation.program.application; + +import net.catsnap.CatsnapReservation.program.domain.Program; +import net.catsnap.CatsnapReservation.program.application.dto.request.ProgramCreateRequest; +import net.catsnap.CatsnapReservation.program.application.dto.response.ProgramResponse; +import net.catsnap.CatsnapReservation.program.infrastructure.repository.ProgramRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Program 애그리거트의 Application Service + * + *

도메인 계층의 객체들을 오케스트레이션하여 비즈니스 유스케이스를 구현합니다. + */ +@Service +public class ProgramService { + + private final ProgramRepository programRepository; + + public ProgramService(ProgramRepository programRepository) { + this.programRepository = programRepository; + } + + /** + * 프로그램 생성 유스케이스 + *

+ * 원시 타입을 도메인 엔티티에 전달하고, 도메인 엔티티가 VO 생성 및 검증을 담당합니다. + * + * @param photographerId 작가 ID + * @param request 프로그램 생성 요청 정보 + * @return 생성된 프로그램 응답 + */ + @Transactional + public ProgramResponse createProgram(Long photographerId, ProgramCreateRequest request) { + Program program = Program.create( + photographerId, + request.title(), + request.description(), + request.price(), + request.durationMinutes() + ); + + Program savedProgram = programRepository.save(program); + + return new ProgramResponse(savedProgram.getId()); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/request/ProgramCreateRequest.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/request/ProgramCreateRequest.java new file mode 100644 index 0000000..0956fdc --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/request/ProgramCreateRequest.java @@ -0,0 +1,25 @@ +package net.catsnap.CatsnapReservation.program.application.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +/** + * 프로그램 생성 요청 DTO + */ +public record ProgramCreateRequest( + @NotBlank(message = "프로그램 제목은 필수입니다") + String title, + + String description, + + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + Long price, + + @NotNull(message = "소요 시간은 필수입니다") + @Min(value = 1, message = "소요 시간은 1분 이상이어야 합니다") + Integer durationMinutes +) { + +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/response/ProgramResponse.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/response/ProgramResponse.java new file mode 100644 index 0000000..d42cacb --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/application/dto/response/ProgramResponse.java @@ -0,0 +1,9 @@ +package net.catsnap.CatsnapReservation.program.application.dto.response; + +/** + * 프로그램 생성 응답 DTO + */ +public record ProgramResponse( + Long id +) { +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/Program.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/Program.java new file mode 100644 index 0000000..3b0613e --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/Program.java @@ -0,0 +1,186 @@ +package net.catsnap.CatsnapReservation.program.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +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.Getter; +import lombok.NoArgsConstructor; +import net.catsnap.CatsnapReservation.program.domain.vo.Description; +import net.catsnap.CatsnapReservation.program.domain.vo.Duration; +import net.catsnap.CatsnapReservation.program.domain.vo.Price; +import net.catsnap.CatsnapReservation.program.domain.vo.Title; +import net.catsnap.CatsnapReservation.program.infrastructure.converter.DescriptionConverter; +import net.catsnap.CatsnapReservation.program.infrastructure.converter.DurationConverter; +import net.catsnap.CatsnapReservation.program.infrastructure.converter.PriceConverter; +import net.catsnap.CatsnapReservation.program.infrastructure.converter.TitleConverter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 작가 프로그램 엔티티 (Aggregate Root) + *

+ * 작가가 제공하는 촬영 프로그램을 표현하는 도메인 엔티티입니다. + * 프로그램 제목, 설명, 가격, 소요 시간 정보를 관리합니다. + */ +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Program { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long photographerId; + + @Column(nullable = false, length = 100) + @Convert(converter = TitleConverter.class) + private Title title; + + @Column(length = 500) + @Convert(converter = DescriptionConverter.class) + private Description description; + + @Column(nullable = false) + @Convert(converter = PriceConverter.class) + private Price price; + + @Column(nullable = false) + @Convert(converter = DurationConverter.class) + private Duration duration; + + private LocalDateTime deletedAt; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + private Program( + Long photographerId, + Title title, + Description description, + Price price, + Duration duration + ) { + validatePhotographerId(photographerId); + this.photographerId = photographerId; + this.title = title; + this.description = description; + this.price = price; + this.duration = duration; + } + + /** + * 새로운 프로그램 생성 + *

+ * 원시 타입을 받아 내부에서 VO를 생성하고 검증합니다. + * 도메인 엔티티가 자신의 불변식(invariant)을 보장합니다. + * + * @param photographerId 작가 ID + * @param titleValue 프로그램 제목 + * @param descriptionValue 프로그램 설명 (nullable) + * @param priceValue 가격 + * @param durationMinutes 소요 시간 (분) + * @return 생성된 Program + */ + public static Program create( + Long photographerId, + String titleValue, + String descriptionValue, + Long priceValue, + Integer durationMinutes + ) { + Title title = new Title(titleValue); + Description description = new Description(descriptionValue); + Price price = new Price(priceValue); + Duration duration = new Duration(durationMinutes); + + return new Program(photographerId, title, description, price, duration); + } + + /** + * 프로그램 정보 수정 + *

+ * 원시 타입을 받아 내부에서 VO를 생성하고 검증합니다. + * + * @param titleValue 프로그램 제목 + * @param descriptionValue 프로그램 설명 (nullable) + * @param priceValue 가격 + * @param durationMinutes 소요 시간 (분) + */ + public void update( + String titleValue, + String descriptionValue, + Long priceValue, + Integer durationMinutes + ) { + this.title = new Title(titleValue); + this.description = new Description(descriptionValue); + this.price = new Price(priceValue); + this.duration = new Duration(durationMinutes); + } + + /** + * 프로그램 소프트 삭제 + */ + public void delete() { + this.deletedAt = LocalDateTime.now(); + } + + /** + * 삭제 여부 확인 + */ + public boolean isDeleted() { + return deletedAt != null; + } + + /** + * 작가 소유권 확인 + */ + public boolean isOwnedBy(Long photographerId) { + return this.photographerId.equals(photographerId); + } + + private void validatePhotographerId(Long photographerId) { + if (photographerId == null) { + throw new IllegalArgumentException("작가 ID는 필수입니다."); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Program program = (Program) o; + return Objects.equals(id, program.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return String.format("Program{id=%d, photographerId=%d, title=%s}", + id, photographerId, title); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Description.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Description.java new file mode 100644 index 0000000..f985b97 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Description.java @@ -0,0 +1,59 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import java.util.Objects; +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; + +/** + * 프로그램 설명 값 객체 (Value Object) + * + *

프로그램의 간단 설명을 표현하는 불변 객체입니다. + * null을 허용하며, 값이 있는 경우 최대 500자까지 허용합니다. + */ +@Getter +public class Description { + + private static final int MAX_LENGTH = 500; + + private final String value; + + public Description(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value != null && value.length() > MAX_LENGTH) { + String message = String.format("프로그램 설명은 %d자 이하여야 합니다. 현재: %d자", + MAX_LENGTH, value.length()); + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, message); + } + } + + public boolean isEmpty() { + return value == null || value.isBlank(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Description that = (Description) o; + return Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value == null ? "" : value; + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Duration.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Duration.java new file mode 100644 index 0000000..b05d319 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Duration.java @@ -0,0 +1,69 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import java.util.Objects; +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; + +/** + * 프로그램 소요 시간 값 객체 (Value Object) + * + *

프로그램의 소요 시간(분)을 표현하는 불변 객체입니다. + */ +@Getter +public class Duration { + + private static final int MIN_VALUE = 1; + + private final Integer value; + + public Duration(Integer value) { + validate(value); + this.value = value; + } + + private void validate(Integer value) { + if (value == null) { + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, "소요 시간은 필수입니다."); + } + if (value < MIN_VALUE) { + String message = String.format("소요 시간은 %d분 이상이어야 합니다. 현재: %d분", MIN_VALUE, value); + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, message); + } + } + + public int toHours() { + return value / 60; + } + + public int remainingMinutes() { + return value % 60; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Duration duration = (Duration) o; + return Objects.equals(value, duration.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + if (value >= 60) { + int hours = toHours(); + int minutes = remainingMinutes(); + return minutes > 0 ? hours + "시간 " + minutes + "분" : hours + "시간"; + } + return value + "분"; + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Price.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Price.java new file mode 100644 index 0000000..0e6357f --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Price.java @@ -0,0 +1,60 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import java.util.Objects; +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; + +/** + * 프로그램 가격 값 객체 (Value Object) + * + *

프로그램의 가격(원)을 표현하는 불변 객체입니다. + */ +@Getter +public class Price { + + private static final long MIN_VALUE = 0L; + + private final Long value; + + public Price(Long value) { + validate(value); + this.value = value; + } + + private void validate(Long value) { + if (value == null) { + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, "가격은 필수입니다."); + } + if (value < MIN_VALUE) { + String message = String.format("가격은 %d원 이상이어야 합니다. 현재: %d원", MIN_VALUE, value); + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, message); + } + } + + public boolean isFree() { + return value == 0L; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Price price = (Price) o; + return Objects.equals(value, price.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value + "원"; + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Title.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Title.java new file mode 100644 index 0000000..893d57d --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/domain/vo/Title.java @@ -0,0 +1,58 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import java.util.Objects; +import lombok.Getter; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainErrorCode; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; + +/** + * 프로그램 제목 값 객체 (Value Object) + * + *

프로그램의 제목을 표현하는 불변 객체입니다. + */ +@Getter +public class Title { + + private static final int MIN_LENGTH = 1; + private static final int MAX_LENGTH = 100; + + private final String value; + + public Title(String value) { + validate(value); + this.value = value; + } + + private void validate(String value) { + if (value == null || value.isBlank()) { + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, "프로그램 제목은 필수입니다."); + } + if (value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) { + String message = String.format("프로그램 제목은 %d자 이상 %d자 이하여야 합니다. 현재: %d자", + MIN_LENGTH, MAX_LENGTH, value.length()); + throw new DomainException(DomainErrorCode.DOMAIN_CONSTRAINT_VIOLATION, message); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Title title = (Title) o; + return Objects.equals(value, title.value); + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + + @Override + public String toString() { + return value; + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverter.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverter.java new file mode 100644 index 0000000..cd52613 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverter.java @@ -0,0 +1,22 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import net.catsnap.CatsnapReservation.program.domain.vo.Description; + +/** + * JPA에서 Description 값 객체를 String으로 변환하는 Converter + */ +@Converter(autoApply = true) +public class DescriptionConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(Description attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public Description convertToEntityAttribute(String dbData) { + return dbData == null ? null : new Description(dbData); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverter.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverter.java new file mode 100644 index 0000000..66a04e0 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverter.java @@ -0,0 +1,22 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import net.catsnap.CatsnapReservation.program.domain.vo.Duration; + +/** + * JPA에서 Duration 값 객체를 Integer로 변환하는 Converter + */ +@Converter(autoApply = true) +public class DurationConverter implements AttributeConverter { + + @Override + public Integer convertToDatabaseColumn(Duration attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public Duration convertToEntityAttribute(Integer dbData) { + return dbData == null ? null : new Duration(dbData); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverter.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverter.java new file mode 100644 index 0000000..348f3df --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverter.java @@ -0,0 +1,22 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import net.catsnap.CatsnapReservation.program.domain.vo.Price; + +/** + * JPA에서 Price 값 객체를 Long으로 변환하는 Converter + */ +@Converter(autoApply = true) +public class PriceConverter implements AttributeConverter { + + @Override + public Long convertToDatabaseColumn(Price attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public Price convertToEntityAttribute(Long dbData) { + return dbData == null ? null : new Price(dbData); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverter.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverter.java new file mode 100644 index 0000000..4e51836 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverter.java @@ -0,0 +1,22 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import net.catsnap.CatsnapReservation.program.domain.vo.Title; + +/** + * JPA에서 Title 값 객체를 String으로 변환하는 Converter + */ +@Converter(autoApply = true) +public class TitleConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(Title attribute) { + return attribute == null ? null : attribute.getValue(); + } + + @Override + public Title convertToEntityAttribute(String dbData) { + return dbData == null ? null : new Title(dbData); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramRepository.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramRepository.java new file mode 100644 index 0000000..b3ccf8f --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramRepository.java @@ -0,0 +1,23 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.repository; + +import java.util.Optional; +import net.catsnap.CatsnapReservation.program.domain.Program; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +/** + * 프로그램 Repository + */ +public interface ProgramRepository extends JpaRepository, + JpaSpecificationExecutor { + + /** + * 프로그램 ID로 조회 (삭제 여부 상관없이) + *

+ * 리뷰 조회 등 삭제된 프로그램도 보여야 하는 경우 사용 + * + * @param id 프로그램 ID + * @return 프로그램 (존재하지 않으면 empty) + */ + Optional findById(Long id); +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecification.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecification.java new file mode 100644 index 0000000..471977b --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecification.java @@ -0,0 +1,40 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.repository; + +import net.catsnap.CatsnapReservation.program.domain.Program; +import org.springframework.data.jpa.domain.Specification; + +/** + * 프로그램 조회 Specification + *

+ * 도메인 언어로 조회 조건을 표현합니다. + */ +public class ProgramSpecification { + + private ProgramSpecification() { + } + + /** + * 활성 상태인 프로그램 (삭제되지 않은) + *

+ * 예약 목록 조회 시 사용 + */ + public static Specification isActive() { + return (root, query, cb) -> cb.isNull(root.get("deletedAt")); + } + + /** + * 삭제된 프로그램 + */ + public static Specification isDeleted() { + return (root, query, cb) -> cb.isNotNull(root.get("deletedAt")); + } + + /** + * 특정 작가의 프로그램 + * + * @param photographerId 작가 ID + */ + public static Specification belongsTo(Long photographerId) { + return (root, query, cb) -> cb.equal(root.get("photographerId"), photographerId); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/program/presentation/ProgramController.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/presentation/ProgramController.java new file mode 100644 index 0000000..6ead2a1 --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/program/presentation/ProgramController.java @@ -0,0 +1,48 @@ +package net.catsnap.CatsnapReservation.program.presentation; + +import jakarta.validation.Valid; +import net.catsnap.CatsnapReservation.program.application.ProgramService; +import net.catsnap.CatsnapReservation.program.application.dto.request.ProgramCreateRequest; +import net.catsnap.CatsnapReservation.program.application.dto.response.ProgramResponse; +import net.catsnap.CatsnapReservation.shared.presentation.response.ResultResponse; +import net.catsnap.CatsnapReservation.shared.presentation.success.PresentationSuccessCode; +import net.catsnap.CatsnapReservation.shared.presentation.web.resolver.UserId; +import net.catsnap.shared.auth.LoginPhotographer; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 프로그램 관련 API 컨트롤러 + * + *

작가의 프로그램 생성, 수정, 삭제, 조회 기능을 제공합니다.

+ */ +@RestController +@RequestMapping("/reservation/program") +public class ProgramController { + + private final ProgramService programService; + + public ProgramController(ProgramService programService) { + this.programService = programService; + } + + /** + * 프로그램 생성 API + * + * @param photographerId 인증된 작가 ID + * @param request 프로그램 생성 요청 정보 + * @return 생성된 프로그램 정보 + */ + @PostMapping + @LoginPhotographer + public ResponseEntity> createProgram( + @UserId Long photographerId, + @Valid @RequestBody ProgramCreateRequest request + ) { + ProgramResponse response = programService.createProgram(photographerId, request); + return ResultResponse.of(PresentationSuccessCode.CREATE, response); + } +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java index 7f5fa91..a232a2f 100644 --- a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/config/WebMvcConfig.java @@ -1,14 +1,25 @@ package net.catsnap.CatsnapReservation.shared.presentation.web.config; +import java.util.List; import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.AdminInterceptor; import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.AnyUserInterceptor; import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginModelInterceptor; import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginPhotographerInterceptor; import net.catsnap.CatsnapReservation.shared.presentation.web.interceptor.LoginUserInterceptor; +import net.catsnap.CatsnapReservation.shared.presentation.web.resolver.UserIdArgumentResolver; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +/** + * Spring MVC 설정 클래스. + *

+ * 인터셉터와 Argument Resolver를 등록하여 웹 요청 처리를 구성합니다. + *

+ * + * @see WebMvcConfigurer + */ @Configuration public class WebMvcConfig implements WebMvcConfigurer { @@ -17,21 +28,42 @@ public class WebMvcConfig implements WebMvcConfigurer { private final LoginUserInterceptor loginUserInterceptor; private final LoginPhotographerInterceptor loginPhotographerInterceptor; private final LoginModelInterceptor loginModelInterceptor; + private final UserIdArgumentResolver userIdArgumentResolver; + /** + * WebMvcConfig 생성자. + * + * @param adminInterceptor 관리자 권한 검증 인터셉터 + * @param anyUserInterceptor 모든 사용자 권한 검증 인터셉터 + * @param loginUserInterceptor 일반 사용자 로그인 검증 인터셉터 + * @param loginPhotographerInterceptor 작가 로그인 검증 인터셉터 + * @param loginModelInterceptor 모델 로그인 검증 인터셉터 + * @param userIdArgumentResolver 사용자 ID Argument Resolver + */ public WebMvcConfig( AdminInterceptor adminInterceptor, AnyUserInterceptor anyUserInterceptor, LoginUserInterceptor loginUserInterceptor, LoginPhotographerInterceptor loginPhotographerInterceptor, - LoginModelInterceptor loginModelInterceptor + LoginModelInterceptor loginModelInterceptor, + UserIdArgumentResolver userIdArgumentResolver ) { this.adminInterceptor = adminInterceptor; this.anyUserInterceptor = anyUserInterceptor; this.loginUserInterceptor = loginUserInterceptor; this.loginPhotographerInterceptor = loginPhotographerInterceptor; this.loginModelInterceptor = loginModelInterceptor; + this.userIdArgumentResolver = userIdArgumentResolver; } + /** + * 인터셉터를 등록합니다. + *

+ * 등록 순서대로 인터셉터가 실행됩니다. + *

+ * + * @param registry 인터셉터 레지스트리 + */ @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(adminInterceptor); @@ -40,4 +72,14 @@ public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginPhotographerInterceptor); registry.addInterceptor(loginModelInterceptor); } + + /** + * Argument Resolver를 등록합니다. + * + * @param resolvers Argument Resolver 목록 + */ + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(userIdArgumentResolver); + } } diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserId.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserId.java new file mode 100644 index 0000000..f8f5d0a --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserId.java @@ -0,0 +1,39 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.resolver; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * 컨트롤러 메서드 파라미터에 현재 인증된 사용자의 ID를 주입하는 어노테이션입니다. + *

+ * 이 어노테이션이 붙은 {@code Long} 타입 파라미터에 Passport에서 추출한 사용자 ID가 주입됩니다. + * 반드시 인증된 요청에서만 사용해야 합니다 ({@code @LoginPhotographer}, {@code @LoginModel} 등과 함께 사용). + *

+ * + *

사용 예시

+ *
{@code
+ * @RestController
+ * public class ProgramController {
+ *
+ *     @LoginPhotographer
+ *     @PostMapping("/programs")
+ *     public ProgramResponse createProgram(
+ *         @UserId Long photographerId,  // Passport에서 userId 추출
+ *         @RequestBody ProgramCreateRequest request
+ *     ) {
+ *         return programService.createProgram(photographerId, request);
+ *     }
+ * }
+ * }
+ * + * @see UserIdArgumentResolver + */ +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface UserId { + +} diff --git a/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolver.java b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolver.java new file mode 100644 index 0000000..76ebbec --- /dev/null +++ b/reservation/src/main/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolver.java @@ -0,0 +1,83 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.resolver; + +import jakarta.servlet.http.HttpServletRequest; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import net.catsnap.shared.passport.domain.exception.ExpiredPassportException; +import net.catsnap.shared.passport.domain.exception.InvalidPassportException; +import net.catsnap.shared.passport.domain.exception.PassportParsingException; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * {@link UserId} 어노테이션이 붙은 파라미터에 사용자 ID를 주입하는 ArgumentResolver입니다. + *

+ * 게이트웨이에서 발급한 Passport 헤더(X-Passport)를 파싱하여 사용자 ID를 추출합니다. + *

+ * + * @see UserId + * @see PassportHandler + */ +@Component +public class UserIdArgumentResolver implements HandlerMethodArgumentResolver { + + private final PassportHandler passportHandler; + + public UserIdArgumentResolver(PassportHandler passportHandler) { + this.passportHandler = passportHandler; + } + + /** + * 이 ArgumentResolver가 해당 파라미터를 처리할 수 있는지 확인합니다. + * + * @param parameter 메서드 파라미터 + * @return {@link UserId} 어노테이션이 있으면 true + */ + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class); + } + + /** + * Passport에서 사용자 ID를 추출하여 반환합니다. + * + * @param parameter 메서드 파라미터 + * @param mavContainer ModelAndViewContainer + * @param webRequest 웹 요청 + * @param binderFactory WebDataBinderFactory + * @return 사용자 ID (Long) + * @throws PresentationException Passport가 없거나 유효하지 않은 경우 + */ + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new PresentationException(PresentationErrorCode.UNAUTHORIZED); + } + + String signedPassport = request.getHeader(PassportHandler.PASSPORT_KEY); + if (signedPassport == null || signedPassport.isBlank()) { + throw new PresentationException(PresentationErrorCode.UNAUTHORIZED); + } + + try { + Passport passport = passportHandler.parse(signedPassport); + return passport.userId(); + } catch (PassportParsingException | InvalidPassportException e) { + throw new PresentationException(PresentationErrorCode.INVALID_PASSPORT); + } catch (ExpiredPassportException e) { + throw new PresentationException(PresentationErrorCode.EXPIRED_PASSPORT); + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/architecture/ControllerAuthenticationArchitectureTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/architecture/ControllerAuthenticationArchitectureTest.java new file mode 100644 index 0000000..026c41f --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/architecture/ControllerAuthenticationArchitectureTest.java @@ -0,0 +1,81 @@ +package net.catsnap.CatsnapReservation.architecture; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods; + +import com.tngtech.archunit.core.domain.JavaAnnotation; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import net.catsnap.shared.auth.Authentication; +import org.springframework.web.bind.annotation.RestController; + +/** + * 컨트롤러 인증 아키텍처 테스트 + *

+ * 모든 컨트롤러 메서드가 Authentication 메타 어노테이션을 가져야 함을 검증합니다. 테스트 코드 내의 컨트롤러는 검증 대상에서 제외됩니다. + *

+ */ +@AnalyzeClasses( + packages = "net.catsnap.CatsnapReservation", + importOptions = {ImportOption.DoNotIncludeTests.class} +) +class ControllerAuthenticationArchitectureTest { + + /** + * 모든 컨트롤러의 public 메서드는 Authentication 메타 어노테이션을 가져야 합니다. + *

+ * Authentication 메타 어노테이션을 가진 어노테이션: + *

+ *

+ */ + @ArchTest + static final ArchRule 모든_컨트롤러_메서드는_Authentication_어노테이션을_가져야_한다 = + methods() + .that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class) + .and().arePublic() + .and().areDeclaredInClassesThat().resideInAPackage("..presentation..") + .should(haveAuthenticationMetaAnnotation()) + .because("모든 API 엔드포인트는 명시적인 권한 정의가 필요합니다"); + + /** + * Authentication 메타 어노테이션을 가지고 있는지 확인하는 커스텀 조건 + */ + private static ArchCondition haveAuthenticationMetaAnnotation() { + return new ArchCondition<>("have @Authentication meta-annotation") { + @Override + public void check(JavaMethod method, ConditionEvents events) { + boolean hasAuthenticationAnnotation = method.getAnnotations().stream() + .anyMatch( + ControllerAuthenticationArchitectureTest::hasAuthenticationMetaAnnotation); + + if (!hasAuthenticationAnnotation) { + String message = String.format( + "메서드 %s.%s()는 @Authentication 메타 어노테이션이 필요합니다. " + + "(@Admin, @AnyUser, @LoginUser, @LoginPhotographer, @LoginModel 중 하나를 사용하세요)", + method.getOwner().getSimpleName(), + method.getName() + ); + events.add(SimpleConditionEvent.violated(method, message)); + } + } + }; + } + + /** + * 어노테이션이 Authentication 메타 어노테이션을 가지고 있는지 확인 + */ + private static boolean hasAuthenticationMetaAnnotation(JavaAnnotation annotation) { + return annotation.getRawType().isAnnotatedWith(Authentication.class); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/application/ProgramServiceTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/application/ProgramServiceTest.java new file mode 100644 index 0000000..fd12531 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/application/ProgramServiceTest.java @@ -0,0 +1,170 @@ +package net.catsnap.CatsnapReservation.program.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.program.domain.Program; +import net.catsnap.CatsnapReservation.program.application.dto.request.ProgramCreateRequest; +import net.catsnap.CatsnapReservation.program.application.dto.response.ProgramResponse; +import net.catsnap.CatsnapReservation.program.infrastructure.repository.ProgramRepository; +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +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.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@DisplayName("ProgramService 통합 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ProgramServiceTest { + + @Autowired + private ProgramService programService; + + @Autowired + private ProgramRepository programRepository; + + @AfterEach + void cleanup() { + programRepository.deleteAll(); + } + + @Nested + class 프로그램_생성_통합_테스트 { + + @Test + @Transactional + void 프로그램_생성에_성공한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅 촬영", + "아름다운 웨딩 스냅을 촬영해드립니다.", + 150000L, + 90 + ); + + // when + ProgramResponse response = programService.createProgram(photographerId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.id()).isNotNull(); + } + + @Test + @Transactional + void 설명_없이_프로그램_생성에_성공한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "프로필 촬영", + null, + 100000L, + 60 + ); + + // when + ProgramResponse response = programService.createProgram(photographerId, request); + + // then + assertThat(response).isNotNull(); + assertThat(response.id()).isNotNull(); + } + + @Test + @Transactional + void 무료_프로그램_생성에_성공한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "무료 상담", + "무료로 상담해드립니다.", + 0L, + 30 + ); + + // when + ProgramResponse response = programService.createProgram(photographerId, request); + + // then + assertThat(response.id()).isNotNull(); + } + + @Test + @Transactional + void 프로그램_생성_시_DB에_저장된다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "스튜디오 촬영", + "스튜디오에서 촬영합니다.", + 200000L, + 120 + ); + + // when + ProgramResponse response = programService.createProgram(photographerId, request); + + // then + Program savedProgram = programRepository.findById(response.id()).orElseThrow(); + assertThat(savedProgram.getPhotographerId()).isEqualTo(photographerId); + assertThat(savedProgram.getTitle().getValue()).isEqualTo("스튜디오 촬영"); + assertThat(savedProgram.isDeleted()).isFalse(); + } + + @Test + void 빈_제목으로_생성_시_예외가_발생한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "", + "설명", + 100000L, + 60 + ); + + // when & then + assertThatThrownBy(() -> programService.createProgram(photographerId, request)) + .isInstanceOf(DomainException.class); + } + + @Test + void 음수_가격으로_생성_시_예외가_발생한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "테스트", + "설명", + -1L, + 60 + ); + + // when & then + assertThatThrownBy(() -> programService.createProgram(photographerId, request)) + .isInstanceOf(DomainException.class); + } + + @Test + void 영분_소요시간으로_생성_시_예외가_발생한다() { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "테스트", + "설명", + 100000L, + 0 + ); + + // when & then + assertThatThrownBy(() -> programService.createProgram(photographerId, request)) + .isInstanceOf(DomainException.class); + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/ProgramTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/ProgramTest.java new file mode 100644 index 0000000..c3f5729 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/ProgramTest.java @@ -0,0 +1,156 @@ +package net.catsnap.CatsnapReservation.program.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Program 엔티티 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ProgramTest { + + @Test + void 프로그램_생성에_성공한다() { + // given + Long photographerId = 1L; + String title = "웨딩 스냅 촬영"; + String description = "아름다운 웨딩 스냅입니다."; + Long price = 150000L; + Integer duration = 90; + + // when + Program program = Program.create(photographerId, title, description, price, duration); + + // then + assertThat(program.getPhotographerId()).isEqualTo(photographerId); + assertThat(program.getTitle().getValue()).isEqualTo(title); + assertThat(program.getDescription().getValue()).isEqualTo(description); + assertThat(program.getPrice().getValue()).isEqualTo(price); + assertThat(program.getDuration().getValue()).isEqualTo(duration); + assertThat(program.isDeleted()).isFalse(); + } + + @Test + void 설명_없이_프로그램_생성에_성공한다() { + // given + Long photographerId = 1L; + + // when + Program program = Program.create(photographerId, "프로필 촬영", null, 100000L, 60); + + // then + assertThat(program.getDescription().isEmpty()).isTrue(); + } + + @Test + void null_작가ID로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> Program.create(null, "웨딩 스냅", "설명", 150000L, 90)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("작가 ID는 필수입니다"); + } + + @Test + void 빈_제목으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> Program.create(1L, "", "설명", 150000L, 90)) + .isInstanceOf(DomainException.class); + } + + @Test + void 음수_가격으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> Program.create(1L, "제목", "설명", -1L, 90)) + .isInstanceOf(DomainException.class); + } + + @Test + void 영분_소요시간으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> Program.create(1L, "제목", "설명", 150000L, 0)) + .isInstanceOf(DomainException.class); + } + + @Test + void 프로그램_정보_수정에_성공한다() { + // given + Program program = createDefaultProgram(); + + // when + program.update("수정된 제목", "수정된 설명", 200000L, 120); + + // then + assertThat(program.getTitle().getValue()).isEqualTo("수정된 제목"); + assertThat(program.getDescription().getValue()).isEqualTo("수정된 설명"); + assertThat(program.getPrice().getValue()).isEqualTo(200000L); + assertThat(program.getDuration().getValue()).isEqualTo(120); + } + + @Test + void 프로그램_삭제에_성공한다() { + // given + Program program = createDefaultProgram(); + + // when + program.delete(); + + // then + assertThat(program.isDeleted()).isTrue(); + assertThat(program.getDeletedAt()).isNotNull(); + } + + @Test + void 삭제되지_않은_프로그램의_isDeleted는_false를_반환한다() { + // given + Program program = createDefaultProgram(); + + // when & then + assertThat(program.isDeleted()).isFalse(); + } + + @Test + void 소유권_확인에_성공한다() { + // given + Long photographerId = 1L; + Program program = Program.create(photographerId, "제목", "설명", 100000L, 60); + + // when & then + assertThat(program.isOwnedBy(1L)).isTrue(); + assertThat(program.isOwnedBy(2L)).isFalse(); + } + + @Test + void 동일한_ID를_가진_프로그램은_같다() { + // given + Program program1 = createDefaultProgram(); + Program program2 = createDefaultProgram(); + + // then + // ID가 null이므로 equals는 ID 기반으로 동작 + // 실제 DB 저장 후에는 ID가 할당되어 비교 가능 + assertThat(program1).isEqualTo(program1); + } + + @Test + void toString이_올바르게_동작한다() { + // given + Program program = Program.create(1L, "웨딩 스냅", "설명", 150000L, 90); + + // when + String result = program.toString(); + + // then + assertThat(result).contains("Program"); + assertThat(result).contains("photographerId=1"); + assertThat(result).contains("웨딩 스냅"); + } + + private Program createDefaultProgram() { + return Program.create(1L, "기본 프로그램", "기본 설명", 100000L, 60); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DescriptionTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DescriptionTest.java new file mode 100644 index 0000000..3947205 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DescriptionTest.java @@ -0,0 +1,135 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Description VO 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DescriptionTest { + + @Test + void 유효한_설명으로_생성에_성공한다() { + // given + String value = "아름다운 웨딩 스냅 촬영입니다."; + + // when + Description description = new Description(value); + + // then + assertThat(description.getValue()).isEqualTo(value); + } + + @Test + void null_설명으로_생성에_성공한다() { + // when + Description description = new Description(null); + + // then + assertThat(description.getValue()).isNull(); + assertThat(description.isEmpty()).isTrue(); + } + + @Test + void 빈_문자열_설명으로_생성에_성공한다() { + // when + Description description = new Description(""); + + // then + assertThat(description.getValue()).isEqualTo(""); + assertThat(description.isEmpty()).isTrue(); + } + + @Test + void 오백자_설명으로_생성에_성공한다() { + // given + String value = "A".repeat(500); + + // when + Description description = new Description(value); + + // then + assertThat(description.getValue()).isEqualTo(value); + assertThat(description.getValue().length()).isEqualTo(500); + } + + @Test + void 오백일자_이상_설명으로_생성_시_예외가_발생한다() { + // given + String value = "A".repeat(501); + + // when & then + assertThatThrownBy(() -> new Description(value)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("500자 이하"); + } + + @Test + void 공백_문자열은_isEmpty가_true를_반환한다() { + // given + Description description = new Description(" "); + + // when & then + assertThat(description.isEmpty()).isTrue(); + } + + @Test + void 값이_있으면_isEmpty가_false를_반환한다() { + // given + Description description = new Description("설명입니다."); + + // when & then + assertThat(description.isEmpty()).isFalse(); + } + + @Test + void 동일한_값을_가진_객체는_같다() { + // given + Description description1 = new Description("설명"); + Description description2 = new Description("설명"); + + // when & then + assertThat(description1).isEqualTo(description2); + assertThat(description1.hashCode()).isEqualTo(description2.hashCode()); + } + + @Test + void null_값을_가진_객체는_같다() { + // given + Description description1 = new Description(null); + Description description2 = new Description(null); + + // when & then + assertThat(description1).isEqualTo(description2); + } + + @Test + void null_값의_toString은_빈_문자열을_반환한다() { + // given + Description description = new Description(null); + + // when + String result = description.toString(); + + // then + assertThat(result).isEqualTo(""); + } + + @Test + void 값이_있는_toString은_값을_반환한다() { + // given + Description description = new Description("설명입니다."); + + // when + String result = description.toString(); + + // then + assertThat(result).isEqualTo("설명입니다."); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DurationTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DurationTest.java new file mode 100644 index 0000000..1470fe6 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/DurationTest.java @@ -0,0 +1,169 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Duration VO 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DurationTest { + + @Test + void 유효한_소요시간으로_생성에_성공한다() { + // given + Integer value = 90; + + // when + Duration duration = new Duration(value); + + // then + assertThat(duration.getValue()).isEqualTo(value); + } + + @Test + void 일분으로_생성에_성공한다() { + // given + Integer value = 1; + + // when + Duration duration = new Duration(value); + + // then + assertThat(duration.getValue()).isEqualTo(1); + } + + @Test + void null_소요시간으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Duration(null)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("소요 시간은 필수입니다"); + } + + @Test + void 영분으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Duration(0)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("1분 이상"); + } + + @Test + void 음수_분으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Duration(-1)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("1분 이상"); + } + + @Test + void toHours가_시간_부분을_반환한다() { + // given + Duration duration = new Duration(90); + + // when + int hours = duration.toHours(); + + // then + assertThat(hours).isEqualTo(1); + } + + @Test + void remainingMinutes가_남은_분을_반환한다() { + // given + Duration duration = new Duration(90); + + // when + int remainingMinutes = duration.remainingMinutes(); + + // then + assertThat(remainingMinutes).isEqualTo(30); + } + + @Test + void 육십분_미만일_때_toHours가_영을_반환한다() { + // given + Duration duration = new Duration(45); + + // when + int hours = duration.toHours(); + + // then + assertThat(hours).isEqualTo(0); + } + + @Test + void 동일한_값을_가진_객체는_같다() { + // given + Duration duration1 = new Duration(90); + Duration duration2 = new Duration(90); + + // when & then + assertThat(duration1).isEqualTo(duration2); + assertThat(duration1.hashCode()).isEqualTo(duration2.hashCode()); + } + + @Test + void 다른_값을_가진_객체는_다르다() { + // given + Duration duration1 = new Duration(60); + Duration duration2 = new Duration(90); + + // when & then + assertThat(duration1).isNotEqualTo(duration2); + } + + @Test + void 육십분_미만일_때_toString이_분_단위로_반환한다() { + // given + Duration duration = new Duration(45); + + // when + String result = duration.toString(); + + // then + assertThat(result).isEqualTo("45분"); + } + + @Test + void 정확히_60분일_때_toString이_시간_단위로_반환한다() { + // given + Duration duration = new Duration(60); + + // when + String result = duration.toString(); + + // then + assertThat(result).isEqualTo("1시간"); + } + + @Test + void 시간과_분이_있을_때_toString이_둘_다_반환한다() { + // given + Duration duration = new Duration(90); + + // when + String result = duration.toString(); + + // then + assertThat(result).isEqualTo("1시간 30분"); + } + + @Test + void 정확히_120분일_때_toString이_시간_단위로_반환한다() { + // given + Duration duration = new Duration(120); + + // when + String result = duration.toString(); + + // then + assertThat(result).isEqualTo("2시간"); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/PriceTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/PriceTest.java new file mode 100644 index 0000000..7b90327 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/PriceTest.java @@ -0,0 +1,99 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Price VO 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PriceTest { + + @Test + void 유효한_가격으로_생성에_성공한다() { + // given + Long value = 150000L; + + // when + Price price = new Price(value); + + // then + assertThat(price.getValue()).isEqualTo(value); + } + + @Test + void 영원으로_생성에_성공한다() { + // given + Long value = 0L; + + // when + Price price = new Price(value); + + // then + assertThat(price.getValue()).isEqualTo(0L); + assertThat(price.isFree()).isTrue(); + } + + @Test + void 유료_가격은_isFree가_false를_반환한다() { + // given + Price price = new Price(10000L); + + // when & then + assertThat(price.isFree()).isFalse(); + } + + @Test + void null_가격으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Price(null)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("가격은 필수입니다"); + } + + @Test + void 음수_가격으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Price(-1L)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("0원 이상"); + } + + @Test + void 동일한_값을_가진_객체는_같다() { + // given + Price price1 = new Price(150000L); + Price price2 = new Price(150000L); + + // when & then + assertThat(price1).isEqualTo(price2); + assertThat(price1.hashCode()).isEqualTo(price2.hashCode()); + } + + @Test + void 다른_값을_가진_객체는_다르다() { + // given + Price price1 = new Price(150000L); + Price price2 = new Price(200000L); + + // when & then + assertThat(price1).isNotEqualTo(price2); + } + + @Test + void toString이_원_단위로_값을_반환한다() { + // given + Price price = new Price(150000L); + + // when + String result = price.toString(); + + // then + assertThat(result).isEqualTo("150000원"); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/TitleTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/TitleTest.java new file mode 100644 index 0000000..a2fe367 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/domain/vo/TitleTest.java @@ -0,0 +1,121 @@ +package net.catsnap.CatsnapReservation.program.domain.vo; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import net.catsnap.CatsnapReservation.shared.domain.error.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("Title VO 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TitleTest { + + @Test + void 유효한_제목으로_생성에_성공한다() { + // given + String value = "웨딩 스냅 촬영"; + + // when + Title title = new Title(value); + + // then + assertThat(title.getValue()).isEqualTo(value); + } + + @Test + void 일자_제목으로_생성에_성공한다() { + // given + String value = "A"; + + // when + Title title = new Title(value); + + // then + assertThat(title.getValue()).isEqualTo(value); + } + + @Test + void 백자_제목으로_생성에_성공한다() { + // given + String value = "A".repeat(100); + + // when + Title title = new Title(value); + + // then + assertThat(title.getValue()).isEqualTo(value); + assertThat(title.getValue().length()).isEqualTo(100); + } + + @Test + void null_제목으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Title(null)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("프로그램 제목은 필수입니다"); + } + + @Test + void 빈_문자열_제목으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Title("")) + .isInstanceOf(DomainException.class) + .hasMessageContaining("프로그램 제목은 필수입니다"); + } + + @Test + void 공백_문자열_제목으로_생성_시_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> new Title(" ")) + .isInstanceOf(DomainException.class) + .hasMessageContaining("프로그램 제목은 필수입니다"); + } + + @Test + void 백일자_이상_제목으로_생성_시_예외가_발생한다() { + // given + String value = "A".repeat(101); + + // when & then + assertThatThrownBy(() -> new Title(value)) + .isInstanceOf(DomainException.class) + .hasMessageContaining("100자 이하"); + } + + @Test + void 동일한_값을_가진_객체는_같다() { + // given + Title title1 = new Title("웨딩 스냅"); + Title title2 = new Title("웨딩 스냅"); + + // when & then + assertThat(title1).isEqualTo(title2); + assertThat(title1.hashCode()).isEqualTo(title2.hashCode()); + } + + @Test + void 다른_값을_가진_객체는_다르다() { + // given + Title title1 = new Title("웨딩 스냅"); + Title title2 = new Title("프로필 촬영"); + + // when & then + assertThat(title1).isNotEqualTo(title2); + } + + @Test + void toString이_값을_반환한다() { + // given + Title title = new Title("웨딩 스냅"); + + // when + String result = title.toString(); + + // then + assertThat(result).isEqualTo("웨딩 스냅"); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverterTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverterTest.java new file mode 100644 index 0000000..cb433f9 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DescriptionConverterTest.java @@ -0,0 +1,91 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.program.domain.vo.Description; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("DescriptionConverter 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DescriptionConverterTest { + + private DescriptionConverter converter; + + @BeforeEach + void setUp() { + converter = new DescriptionConverter(); + } + + @Test + void Description을_문자열로_변환한다() { + // given + Description description = new Description("아름다운 웨딩 스냅입니다."); + + // when + String result = converter.convertToDatabaseColumn(description); + + // then + assertThat(result).isEqualTo("아름다운 웨딩 스냅입니다."); + } + + @Test + void null_Description을_DB_컬럼으로_변환하면_null을_반환한다() { + // when + String result = converter.convertToDatabaseColumn(null); + + // then + assertThat(result).isNull(); + } + + @Test + void null_값을_가진_Description을_DB_컬럼으로_변환하면_null을_반환한다() { + // given + Description description = new Description(null); + + // when + String result = converter.convertToDatabaseColumn(description); + + // then + assertThat(result).isNull(); + } + + @Test + void 문자열을_Description으로_변환한다() { + // given + String dbData = "아름다운 웨딩 스냅입니다."; + + // when + Description result = converter.convertToEntityAttribute(dbData); + + // then + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo("아름다운 웨딩 스냅입니다."); + } + + @Test + void null을_엔티티_속성으로_변환하면_null을_반환한다() { + // when + Description result = converter.convertToEntityAttribute(null); + + // then + assertThat(result).isNull(); + } + + @Test + void 양방향_변환이_올바르게_동작한다() { + // given + Description original = new Description("프로필 촬영 설명입니다."); + + // when + String dbData = converter.convertToDatabaseColumn(original); + Description restored = converter.convertToEntityAttribute(dbData); + + // then + assertThat(restored).isEqualTo(original); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverterTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverterTest.java new file mode 100644 index 0000000..ab11c7f --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/DurationConverterTest.java @@ -0,0 +1,79 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.program.domain.vo.Duration; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("DurationConverter 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class DurationConverterTest { + + private DurationConverter converter; + + @BeforeEach + void setUp() { + converter = new DurationConverter(); + } + + @Test + void Duration을_Integer로_변환한다() { + // given + Duration duration = new Duration(90); + + // when + Integer result = converter.convertToDatabaseColumn(duration); + + // then + assertThat(result).isEqualTo(90); + } + + @Test + void null을_DB_컬럼으로_변환하면_null을_반환한다() { + // when + Integer result = converter.convertToDatabaseColumn(null); + + // then + assertThat(result).isNull(); + } + + @Test + void Integer를_Duration으로_변환한다() { + // given + Integer dbData = 90; + + // when + Duration result = converter.convertToEntityAttribute(dbData); + + // then + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo(90); + } + + @Test + void null을_엔티티_속성으로_변환하면_null을_반환한다() { + // when + Duration result = converter.convertToEntityAttribute(null); + + // then + assertThat(result).isNull(); + } + + @Test + void 양방향_변환이_올바르게_동작한다() { + // given + Duration original = new Duration(120); + + // when + Integer dbData = converter.convertToDatabaseColumn(original); + Duration restored = converter.convertToEntityAttribute(dbData); + + // then + assertThat(restored).isEqualTo(original); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverterTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverterTest.java new file mode 100644 index 0000000..cde0cae --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/PriceConverterTest.java @@ -0,0 +1,93 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.program.domain.vo.Price; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("PriceConverter 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class PriceConverterTest { + + private PriceConverter converter; + + @BeforeEach + void setUp() { + converter = new PriceConverter(); + } + + @Test + void Price를_Long으로_변환한다() { + // given + Price price = new Price(150000L); + + // when + Long result = converter.convertToDatabaseColumn(price); + + // then + assertThat(result).isEqualTo(150000L); + } + + @Test + void null을_DB_컬럼으로_변환하면_null을_반환한다() { + // when + Long result = converter.convertToDatabaseColumn(null); + + // then + assertThat(result).isNull(); + } + + @Test + void Long을_Price로_변환한다() { + // given + Long dbData = 150000L; + + // when + Price result = converter.convertToEntityAttribute(dbData); + + // then + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo(150000L); + } + + @Test + void null을_엔티티_속성으로_변환하면_null을_반환한다() { + // when + Price result = converter.convertToEntityAttribute(null); + + // then + assertThat(result).isNull(); + } + + @Test + void 양방향_변환이_올바르게_동작한다() { + // given + Price original = new Price(200000L); + + // when + Long dbData = converter.convertToDatabaseColumn(original); + Price restored = converter.convertToEntityAttribute(dbData); + + // then + assertThat(restored).isEqualTo(original); + } + + @Test + void 영원_가격도_양방향_변환이_동작한다() { + // given + Price original = new Price(0L); + + // when + Long dbData = converter.convertToDatabaseColumn(original); + Price restored = converter.convertToEntityAttribute(dbData); + + // then + assertThat(restored).isEqualTo(original); + assertThat(restored.isFree()).isTrue(); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverterTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverterTest.java new file mode 100644 index 0000000..4d2398f --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/converter/TitleConverterTest.java @@ -0,0 +1,79 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.converter; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.program.domain.vo.Title; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; + +@DisplayName("TitleConverter 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TitleConverterTest { + + private TitleConverter converter; + + @BeforeEach + void setUp() { + converter = new TitleConverter(); + } + + @Test + void Title을_문자열로_변환한다() { + // given + Title title = new Title("웨딩 스냅 촬영"); + + // when + String result = converter.convertToDatabaseColumn(title); + + // then + assertThat(result).isEqualTo("웨딩 스냅 촬영"); + } + + @Test + void null을_DB_컬럼으로_변환하면_null을_반환한다() { + // when + String result = converter.convertToDatabaseColumn(null); + + // then + assertThat(result).isNull(); + } + + @Test + void 문자열을_Title로_변환한다() { + // given + String dbData = "웨딩 스냅 촬영"; + + // when + Title result = converter.convertToEntityAttribute(dbData); + + // then + assertThat(result).isNotNull(); + assertThat(result.getValue()).isEqualTo("웨딩 스냅 촬영"); + } + + @Test + void null을_엔티티_속성으로_변환하면_null을_반환한다() { + // when + Title result = converter.convertToEntityAttribute(null); + + // then + assertThat(result).isNull(); + } + + @Test + void 양방향_변환이_올바르게_동작한다() { + // given + Title original = new Title("프로필 촬영"); + + // when + String dbData = converter.convertToDatabaseColumn(original); + Title restored = converter.convertToEntityAttribute(dbData); + + // then + assertThat(restored).isEqualTo(original); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecificationTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecificationTest.java new file mode 100644 index 0000000..ec635ed --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/infrastructure/repository/ProgramSpecificationTest.java @@ -0,0 +1,63 @@ +package net.catsnap.CatsnapReservation.program.infrastructure.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.catsnap.CatsnapReservation.program.domain.Program; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.domain.Specification; + +@DisplayName("ProgramSpecification 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ProgramSpecificationTest { + + @Test + void isActive_Specification이_생성된다() { + // when + Specification spec = ProgramSpecification.isActive(); + + // then + assertThat(spec).isNotNull(); + } + + @Test + void isDeleted_Specification이_생성된다() { + // when + Specification spec = ProgramSpecification.isDeleted(); + + // then + assertThat(spec).isNotNull(); + } + + @Test + void belongsTo_Specification이_생성된다() { + // when + Specification spec = ProgramSpecification.belongsTo(1L); + + // then + assertThat(spec).isNotNull(); + } + + @Test + void Specification을_조합할_수_있다() { + // when + Specification spec = ProgramSpecification.isActive() + .and(ProgramSpecification.belongsTo(1L)); + + // then + assertThat(spec).isNotNull(); + } + + @Test + void or_조건으로_Specification을_조합할_수_있다() { + // when + Specification spec = ProgramSpecification.belongsTo(1L) + .or(ProgramSpecification.belongsTo(2L)); + + // then + assertThat(spec).isNotNull(); + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/program/presentation/ProgramControllerTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/presentation/ProgramControllerTest.java new file mode 100644 index 0000000..747feda --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/program/presentation/ProgramControllerTest.java @@ -0,0 +1,266 @@ +package net.catsnap.CatsnapReservation.program.presentation; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.catsnap.CatsnapReservation.program.application.ProgramService; +import net.catsnap.CatsnapReservation.program.application.dto.request.ProgramCreateRequest; +import net.catsnap.CatsnapReservation.program.application.dto.response.ProgramResponse; +import net.catsnap.CatsnapReservation.shared.fixture.PassportTestHelper; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.success.PresentationSuccessCode; +import net.catsnap.CatsnapReservation.shared.presentation.web.config.PassportConfig; +import net.catsnap.CatsnapReservation.shared.presentation.web.resolver.UserIdArgumentResolver; +import org.junit.jupiter.api.DisplayName; +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.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ProgramController.class) +@DisplayName("ProgramController 테스트") +@Import({PassportConfig.class, PassportTestHelper.class, UserIdArgumentResolver.class}) +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ProgramControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private PassportTestHelper passportTestHelper; + + @MockitoBean + private ProgramService programService; + + @Nested + class 프로그램_생성 { + + @Test + void 프로그램_생성에_성공한다() throws Exception { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅 촬영", + "아름다운 웨딩 스냅을 촬영해드립니다.", + 150000L, + 90 + ); + + ProgramResponse response = new ProgramResponse(1L); + + when(programService.createProgram(eq(photographerId), any(ProgramCreateRequest.class))) + .thenReturn(response); + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(PresentationSuccessCode.CREATE.getCode())) + .andExpect(jsonPath("$.data.id").value(1L)); + } + + @Test + void 설명_없이_프로그램_생성에_성공한다() throws Exception { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "프로필 촬영", + null, + 100000L, + 60 + ); + + ProgramResponse response = new ProgramResponse(1L); + + when(programService.createProgram(eq(photographerId), any(ProgramCreateRequest.class))) + .thenReturn(response); + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").value(1L)); + } + + @Test + void 제목_누락_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + String invalidRequest = """ + { + "description": "설명입니다.", + "price": 100000, + "durationMinutes": 60 + } + """; + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 빈_제목_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "", + "설명입니다.", + 100000L, + 60 + ); + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 가격_누락_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + String invalidRequest = """ + { + "title": "웨딩 스냅", + "description": "설명입니다.", + "durationMinutes": 60 + } + """; + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 음수_가격_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅", + "설명입니다.", + -1L, + 60 + ); + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 소요시간_누락_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + String invalidRequest = """ + { + "title": "웨딩 스냅", + "description": "설명입니다.", + "price": 100000 + } + """; + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(invalidRequest)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 영분_소요시간_시_예외가_발생한다() throws Exception { + // given + Long photographerId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅", + "설명입니다.", + 100000L, + 0 + ); + + // when & then + mockMvc.perform( + passportTestHelper.withPhotographer(post("/reservation/program"), photographerId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.INVALID_REQUEST_BODY.getCode())); + } + + @Test + void 인증되지_않은_사용자는_접근할_수_없다() throws Exception { + // given + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅", + "설명입니다.", + 100000L, + 60 + ); + + // when & then + mockMvc.perform( + passportTestHelper.withAnonymous(post("/reservation/program")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.FORBIDDEN.getCode())); + } + + @Test + void 모델_권한으로는_접근할_수_없다() throws Exception { + // given + Long modelId = 1L; + ProgramCreateRequest request = new ProgramCreateRequest( + "웨딩 스냅", + "설명입니다.", + 100000L, + 60 + ); + + // when & then + mockMvc.perform( + passportTestHelper.withModel(post("/reservation/program"), modelId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(PresentationErrorCode.FORBIDDEN.getCode())); + } + } +} diff --git a/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolverTest.java b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolverTest.java new file mode 100644 index 0000000..aa50e44 --- /dev/null +++ b/reservation/src/test/java/net/catsnap/CatsnapReservation/shared/presentation/web/resolver/UserIdArgumentResolverTest.java @@ -0,0 +1,155 @@ +package net.catsnap.CatsnapReservation.shared.presentation.web.resolver; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import jakarta.servlet.http.HttpServletRequest; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationErrorCode; +import net.catsnap.CatsnapReservation.shared.presentation.error.PresentationException; +import net.catsnap.shared.auth.CatsnapAuthority; +import net.catsnap.shared.passport.domain.Passport; +import net.catsnap.shared.passport.domain.PassportHandler; +import net.catsnap.shared.passport.infrastructure.BinaryPassportHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.core.MethodParameter; +import org.springframework.web.context.request.NativeWebRequest; + +@DisplayName("UserIdArgumentResolver 테스트") +@DisplayNameGeneration(ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class UserIdArgumentResolverTest { + + private static final String TEST_SECRET_KEY = "test-secret-key-for-passport-signing-32bytes!!"; + + private PassportHandler passportHandler; + private UserIdArgumentResolver resolver; + + @BeforeEach + void setUp() { + passportHandler = new BinaryPassportHandler(TEST_SECRET_KEY); + resolver = new UserIdArgumentResolver(passportHandler); + } + + @Nested + class supportsParameter_테스트 { + + @Test + void UserId_어노테이션이_있으면_true를_반환한다() throws NoSuchMethodException { + // given + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("methodWithUserId", Long.class), 0); + + // when + boolean result = resolver.supportsParameter(parameter); + + // then + assertThat(result).isTrue(); + } + + @Test + void UserId_어노테이션이_없으면_false를_반환한다() throws NoSuchMethodException { + // given + MethodParameter parameter = new MethodParameter( + TestController.class.getMethod("methodWithoutUserId", Long.class), 0); + + // when + boolean result = resolver.supportsParameter(parameter); + + // then + assertThat(result).isFalse(); + } + } + + @Nested + class resolveArgument_테스트 { + + @Test + void 유효한_Passport에서_userId를_추출한다() throws Exception { + // given + Long expectedUserId = 123L; + String signedPassport = createSignedPassport(expectedUserId, CatsnapAuthority.PHOTOGRAPHER); + + NativeWebRequest webRequest = mock(NativeWebRequest.class); + HttpServletRequest httpRequest = mock(HttpServletRequest.class); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(httpRequest); + when(httpRequest.getHeader(PassportHandler.PASSPORT_KEY)).thenReturn(signedPassport); + + // when + Object result = resolver.resolveArgument(null, null, webRequest, null); + + // then + assertThat(result).isEqualTo(expectedUserId); + } + + @Test + void Passport_헤더가_없으면_UNAUTHORIZED_예외가_발생한다() { + // given + NativeWebRequest webRequest = mock(NativeWebRequest.class); + HttpServletRequest httpRequest = mock(HttpServletRequest.class); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(httpRequest); + when(httpRequest.getHeader(PassportHandler.PASSPORT_KEY)).thenReturn(null); + + // when & then + assertThatThrownBy(() -> resolver.resolveArgument(null, null, webRequest, null)) + .isInstanceOf(PresentationException.class) + .extracting("resultCode") + .isEqualTo(PresentationErrorCode.UNAUTHORIZED); + } + + @Test + void 빈_Passport_헤더면_UNAUTHORIZED_예외가_발생한다() { + // given + NativeWebRequest webRequest = mock(NativeWebRequest.class); + HttpServletRequest httpRequest = mock(HttpServletRequest.class); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(httpRequest); + when(httpRequest.getHeader(PassportHandler.PASSPORT_KEY)).thenReturn(""); + + // when & then + assertThatThrownBy(() -> resolver.resolveArgument(null, null, webRequest, null)) + .isInstanceOf(PresentationException.class) + .extracting("resultCode") + .isEqualTo(PresentationErrorCode.UNAUTHORIZED); + } + + @Test + void 유효하지_않은_Passport면_INVALID_PASSPORT_예외가_발생한다() { + // given + NativeWebRequest webRequest = mock(NativeWebRequest.class); + HttpServletRequest httpRequest = mock(HttpServletRequest.class); + when(webRequest.getNativeRequest(HttpServletRequest.class)).thenReturn(httpRequest); + when(httpRequest.getHeader(PassportHandler.PASSPORT_KEY)).thenReturn("invalid-passport"); + + // when & then + assertThatThrownBy(() -> resolver.resolveArgument(null, null, webRequest, null)) + .isInstanceOf(PresentationException.class) + .extracting("resultCode") + .isEqualTo(PresentationErrorCode.INVALID_PASSPORT); + } + } + + private String createSignedPassport(Long userId, CatsnapAuthority authority) { + Instant now = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant exp = now.plus(30, ChronoUnit.MINUTES); + Passport passport = new Passport((byte) 1, userId, authority, now, exp); + return passportHandler.sign(passport); + } + + // 테스트용 컨트롤러 + static class TestController { + + public void methodWithUserId(@UserId Long userId) { + } + + public void methodWithoutUserId(Long userId) { + } + } +}