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 메타 어노테이션을 가진 어노테이션:
+ *
+ * - @Admin
+ * - @AnyUser
+ * - @LoginUser
+ * - @LoginPhotographer
+ * - @LoginModel
+ *
+ *
+ */
+ @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) {
+ }
+ }
+}