From 3d6de5cbe859be6cda981e7d04f72452e6e9c300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 20:22:29 +0900 Subject: [PATCH] =?UTF-8?q?T-01=20=EA=B3=B5=ED=86=B5=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flyway·springdoc·Security·Testcontainers·BaseTimeEntity는 이미 있어(auth·member 머지) 재구현하지 않고, 남은 것만 추가한다. - jsoup, software.amazon.awssdk:s3 의존성 추가 (s3-presigner는 별도 아티팩트가 없다 — S3Presigner가 s3 모듈에 통합되어 있다) - S3Config/S3Properties — SesClientConfig 패턴 준용 - GlobalException/GlobalExceptionType — 도메인에 속하지 않는 공유 예외(순서 불일치) - DisplayOrders — display_order 재부여 순수 함수 (INV-3, INV-4) - SoftDeletableEntity — 최상위 엔티티 전용 soft delete 베이스 (하위 트리는 미적용) - Track/Curriculum/Activity/Media ExceptionType 스켈레톤 (아직 호출하는 컨트롤러가 없다 — 각 도메인 티켓에서 항목이 늘어난다) - IntegrationTestSupport — Testcontainers 싱글턴 컨테이너 패턴 (기존 7개 클래스는 각자 컨테이너를 띄운다. 이 베이스로의 이전은 선택적 후속 작업) 테스트: 28개 전체 통과 (기존 7클래스 + 신규 3클래스: DisplayOrders 5, BcsdExceptionType 계약 6, HealthCheck 스모크 1). Refs #13 --- build.gradle.kts | 4 ++ .../activity/exception/ActivityException.java | 18 ++++++ .../exception/ActivityExceptionType.java | 52 +++++++++++++++ .../exception/CurriculumException.java | 18 ++++++ .../exception/CurriculumExceptionType.java | 52 +++++++++++++++ .../global/SoftDeletableEntity.java | 31 +++++++++ .../global/config/S3Config.java | 29 +++++++++ .../global/config/S3Properties.java | 7 +++ .../global/exception/GlobalException.java | 15 +++++ .../global/exception/GlobalExceptionType.java | 56 +++++++++++++++++ .../global/util/DisplayOrders.java | 48 ++++++++++++++ .../media/exception/MediaException.java | 18 ++++++ .../media/exception/MediaExceptionType.java | 52 +++++++++++++++ .../track/exception/TrackException.java | 18 ++++++ .../track/exception/TrackExceptionType.java | 52 +++++++++++++++ src/main/resources/application.yml | 2 + .../IntegrationTestSupport.java | 41 ++++++++++++ .../HealthCheckIntegrationTest.java | 24 +++++++ .../BcsdExceptionTypeContractTest.java | 63 +++++++++++++++++++ .../global/util/DisplayOrdersTest.java | 54 ++++++++++++++++ 20 files changed, 654 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java diff --git a/build.gradle.kts b/build.gradle.kts index 4398bc0..02db550 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,6 +21,10 @@ repositories { dependencies { implementation(platform("software.amazon.awssdk:bom:2.53.2")) implementation("software.amazon.awssdk:ses") + // software.amazon.awssdk:s3에 S3Presigner가 포함되어 있어 별도 s3-presigner 아티팩트가 없다 + // (AWS SDK가 이 버전대에서 통합함 — Maven Central에 s3-presigner 좌표 자체가 존재하지 않는다). + implementation("software.amazon.awssdk:s3") + implementation("org.jsoup:jsoup:1.18.3") implementation("org.springframework.retry:spring-retry:2.0.13") implementation("org.aspectj:aspectjweaver:1.9.25") implementation("org.springframework.boot:spring-boot-starter-data-jpa") diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java new file mode 100644 index 0000000..d31d347 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class ActivityException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public ActivityException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java new file mode 100644 index 0000000..d4abe5d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java @@ -0,0 +1,52 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum ActivityExceptionType implements BcsdExceptionType { + + ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동입니다."), + ; + + private final HttpStatus status; + private final String message; + + ActivityExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedActivityExceptionType(this, detailMessage); + } + + private record DetailedActivityExceptionType(ActivityExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedActivityExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java new file mode 100644 index 0000000..720fb2c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class CurriculumException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public CurriculumException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java new file mode 100644 index 0000000..5bf398e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/curriculum/exception/CurriculumExceptionType.java @@ -0,0 +1,52 @@ +package com.bcsdlab.bcsdinternalapiv2.curriculum.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum CurriculumExceptionType implements BcsdExceptionType { + + CURRICULUM_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 커리큘럼입니다."), + ; + + private final HttpStatus status; + private final String message; + + CurriculumExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedCurriculumExceptionType(this, detailMessage); + } + + private record DetailedCurriculumExceptionType(CurriculumExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedCurriculumExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java new file mode 100644 index 0000000..cbd24a2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/SoftDeletableEntity.java @@ -0,0 +1,31 @@ +package com.bcsdlab.bcsdinternalapiv2.global; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import java.time.Instant; +import lombok.Getter; + +/** + * 최상위 엔티티(트랙 페이지·커리큘럼·활동 카테고리·활동 등)에만 적용하는 soft delete 규약. + * 하위 트리(주차·토픽·세부항목·이미지)는 이 클래스를 쓰지 않고 {@code on delete cascade}로 + * 물리 삭제한다. + * + *

중요: {@code @SQLRestriction("deleted_at is null")}은 이 매핑된 슈퍼클래스가 아니라 + * 이 클래스를 상속하는 각 {@code @Entity} 클래스에 직접 붙여야 한다. Hibernate의 + * {@code @SQLRestriction}은 매핑된 슈퍼클래스로부터 상속되지 않는다. + */ +@Getter +@MappedSuperclass +public abstract class SoftDeletableEntity extends BaseTimeEntity { + + @Column(name = "deleted_at") + private Instant deletedAt; + + public boolean isDeleted() { + return deletedAt != null; + } + + public void delete(Instant now) { + this.deletedAt = now; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java new file mode 100644 index 0000000..5472386 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Config.java @@ -0,0 +1,29 @@ +package com.bcsdlab.bcsdinternalapiv2.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@Configuration +@RequiredArgsConstructor +public class S3Config { + + private final S3Properties s3Properties; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(s3Properties.region())) + .build(); + } + + @Bean + public S3Presigner s3Presigner() { + return S3Presigner.builder() + .region(Region.of(s3Properties.region())) + .build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java new file mode 100644 index 0000000..8557ef5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/S3Properties.java @@ -0,0 +1,7 @@ +package com.bcsdlab.bcsdinternalapiv2.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.s3") +public record S3Properties(String region) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java new file mode 100644 index 0000000..55786ed --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalException.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +public class GlobalException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public GlobalException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java new file mode 100644 index 0000000..1e901e3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/exception/GlobalExceptionType.java @@ -0,0 +1,56 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +import org.springframework.http.HttpStatus; + +/** + * 특정 도메인에 속하지 않고 여러 도메인이 공유하는 예외 타입. + * 예: 정렬 순서 변경 요청 검증({@link com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders})은 + * 트랙·커리큘럼·활동 등 모든 도메인에서 동일한 규칙과 동일한 HTTP 상태를 쓴다. + */ +public enum GlobalExceptionType implements BcsdExceptionType { + + ORDER_IDS_MISMATCH(HttpStatus.BAD_REQUEST, "순서 변경 대상이 기존 항목과 일치하지 않습니다."), + ; + + private final HttpStatus status; + private final String message; + + GlobalExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedGlobalExceptionType(this, detailMessage); + } + + private record DetailedGlobalExceptionType(GlobalExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedGlobalExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java new file mode 100644 index 0000000..9067176 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrders.java @@ -0,0 +1,48 @@ +package com.bcsdlab.bcsdinternalapiv2.global.util; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalExceptionType; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * {@code display_order} 재부여 순수 함수. 특정 엔티티 타입에 의존하지 않는다 — + * 트랙·커리큘럼 주차/토픽·활동 등 정렬 가능한 모든 도메인이 공유한다. + * + *

요청된 id 순서가 대상 id 집합과 정확히 일치하지 않으면(누락·추가·중복) + * {@link GlobalException}을 던지고 아무 값도 반환하지 않는다 — 호출자가 반환값을 받기 전에는 + * 어떤 엔티티도 변경하지 않으므로 "부분 적용" 상태가 존재하지 않는다. + */ +public final class DisplayOrders { + + private DisplayOrders() { + } + + /** + * @param requestedIds 새 순서대로 나열된 id 목록 (배열 인덱스가 곧 새 display_order) + * @param existingIds 현재 그 부모 아래 존재하는 전체 id 집합 + * @return id → 새 display_order(0-base) 매핑 + * @throws GlobalException requestedIds에 중복이 있거나, existingIds와 집합이 다를 때 + */ + public static Map reassign(List requestedIds, Collection existingIds) { + Set requestedSet = new HashSet<>(requestedIds); + if (requestedSet.size() != requestedIds.size()) { + throw new GlobalException(GlobalExceptionType.ORDER_IDS_MISMATCH); + } + + Set existingSet = new HashSet<>(existingIds); + if (!requestedSet.equals(existingSet)) { + throw new GlobalException(GlobalExceptionType.ORDER_IDS_MISMATCH); + } + + Map result = new LinkedHashMap<>(); + for (int i = 0; i < requestedIds.size(); i++) { + result.put(requestedIds.get(i), i); + } + return result; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java new file mode 100644 index 0000000..a690c61 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.media.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class MediaException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public MediaException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java new file mode 100644 index 0000000..39ba6a3 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/media/exception/MediaExceptionType.java @@ -0,0 +1,52 @@ +package com.bcsdlab.bcsdinternalapiv2.media.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum MediaExceptionType implements BcsdExceptionType { + + IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 이미지입니다."), + ; + + private final HttpStatus status; + private final String message; + + MediaExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedMediaExceptionType(this, detailMessage); + } + + private record DetailedMediaExceptionType(MediaExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedMediaExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java new file mode 100644 index 0000000..8edc484 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackException.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.track.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdException; +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; + +public class TrackException extends BcsdException { + + private final BcsdExceptionType exceptionType; + + public TrackException(BcsdExceptionType exceptionType) { + this.exceptionType = exceptionType; + } + + @Override + public BcsdExceptionType getExceptionType() { + return exceptionType; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java new file mode 100644 index 0000000..7456977 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/exception/TrackExceptionType.java @@ -0,0 +1,52 @@ +package com.bcsdlab.bcsdinternalapiv2.track.exception; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.BcsdExceptionType; +import org.springframework.http.HttpStatus; + +public enum TrackExceptionType implements BcsdExceptionType { + + TRACK_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 트랙입니다."), + ; + + private final HttpStatus status; + private final String message; + + TrackExceptionType(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedTrackExceptionType(this, detailMessage); + } + + private record DetailedTrackExceptionType(TrackExceptionType type, String detailMessage) + implements BcsdExceptionType { + + @Override + public HttpStatus getHttpStatus() { + return type.getHttpStatus(); + } + + @Override + public String getMessage() { + return MESSAGE_FORMAT.formatted(type.getMessage(), detailMessage).strip(); + } + + @Override + public BcsdExceptionType withDetail(String detailMessage) { + return new DetailedTrackExceptionType(type, detailMessage); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 02140c0..16f9d74 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -45,3 +45,5 @@ app: transport: ${MAIL_TRANSPORT:sendmail} from: ${MAIL_FROM:no-reply@bcsdlab.com} ses-region: ${AWS_SES_REGION:us-west-2} + s3: + region: ${AWS_S3_REGION:us-west-2} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java new file mode 100644 index 0000000..bfc2636 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/IntegrationTestSupport.java @@ -0,0 +1,41 @@ +package com.bcsdlab.bcsdinternalapiv2; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * 통합 테스트 공통 베이스. Postgres 컨테이너를 테스트 클래스마다 새로 띄우지 않도록, + * 정적 초기화 블록에서 JVM당 한 번만 기동하는 Testcontainers 공식 "싱글턴 컨테이너" 패턴을 쓴다. + * + *

{@code @Testcontainers}/{@code @Container}를 쓰지 않는 이유: 그 조합은 컨테이너 생명주기를 + * 테스트 클래스 단위로 관리해서(클래스마다 기동), 클래스가 늘어날수록 기동 비용이 누적된다. + * 여기서는 정적 필드를 상속받는 모든 하위 클래스가 같은 컨테이너 인스턴스를 공유한다. + * + *

기존 7개 테스트 클래스(auth 4, member 2, 컨텍스트 로딩 1)는 각자 컨테이너를 띄우는 방식을 + * 그대로 쓴다 — 이 클래스로의 이전은 선택적 후속 작업이다(T-01). + */ +@SpringBootTest +@AutoConfigureMockMvc +public abstract class IntegrationTestSupport { + + @ServiceConnection + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + + static { + POSTGRES.start(); + } + + @DynamicPropertySource + static void jwtSecret(DynamicPropertyRegistry registry) { + registry.add("app.jwt.secret", () -> "test-only-secret-key-not-for-production-32bytes-min"); + } + + @Autowired + protected MockMvc mockMvc; +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java new file mode 100644 index 0000000..73eb8ca --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/controller/HealthCheckIntegrationTest.java @@ -0,0 +1,24 @@ +package com.bcsdlab.bcsdinternalapiv2.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * {@link IntegrationTestSupport}(싱글턴 Postgres 컨테이너 베이스)가 실제로 동작하는지 + * 검증하는 최소 스모크 테스트도 겸한다(T-01). + */ +class HealthCheckIntegrationTest extends IntegrationTestSupport { + + @Test + @DisplayName("GET /health는 인증 없이 200과 OK를 반환한다") + void health() throws Exception { + mockMvc.perform(get("/health")) + .andExpect(status().isOk()) + .andExpect(content().string("OK")); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java new file mode 100644 index 0000000..43e2616 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/exception/BcsdExceptionTypeContractTest.java @@ -0,0 +1,63 @@ +package com.bcsdlab.bcsdinternalapiv2.global.exception; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumExceptionType; +import com.bcsdlab.bcsdinternalapiv2.media.exception.MediaExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; + +/** + * 도메인 예외 타입(Track/Curriculum/Activity/Media)은 아직 호출하는 컨트롤러가 없다(T-01은 + * 골격만 정의한다). 그래도 {@link BcsdExceptionType} 계약 — 상태 코드·메시지·{@code withDetail} + * 조합이 실제로 동작하는지는 미리 검증해 둔다. + */ +class BcsdExceptionTypeContractTest { + + @Test + @DisplayName("GlobalExceptionType은 지정한 상태·메시지를 그대로 반환한다") + void globalExceptionType_기본_동작() { + BcsdExceptionType type = GlobalExceptionType.ORDER_IDS_MISMATCH; + + assertThat(type.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(type.getMessage()).isEqualTo("순서 변경 대상이 기존 항목과 일치하지 않습니다."); + } + + @Test + @DisplayName("withDetail은 원본 메시지와 상태를 유지한 채 상세 메시지를 덧붙인다") + void withDetail은_상태를_바꾸지_않고_메시지에_상세를_덧붙인다() { + BcsdExceptionType detailed = GlobalExceptionType.ORDER_IDS_MISMATCH.withDetail("weekId=42"); + + assertThat(detailed.getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(detailed.getMessage()) + .contains("순서 변경 대상이 기존 항목과 일치하지 않습니다.") + .contains("weekId=42"); + } + + @Test + @DisplayName("TrackExceptionType.TRACK_NOT_FOUND는 404다") + void trackExceptionType() { + assertThat(TrackExceptionType.TRACK_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("CurriculumExceptionType.CURRICULUM_NOT_FOUND는 404다") + void curriculumExceptionType() { + assertThat(CurriculumExceptionType.CURRICULUM_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("ActivityExceptionType.ACTIVITY_NOT_FOUND는 404다") + void activityExceptionType() { + assertThat(ActivityExceptionType.ACTIVITY_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("MediaExceptionType.IMAGE_NOT_FOUND는 404다") + void mediaExceptionType() { + assertThat(MediaExceptionType.IMAGE_NOT_FOUND.getHttpStatus()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java new file mode 100644 index 0000000..d9ae8e2 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/global/util/DisplayOrdersTest.java @@ -0,0 +1,54 @@ +package com.bcsdlab.bcsdinternalapiv2.global.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.global.exception.GlobalException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class DisplayOrdersTest { + + @Test + @DisplayName("INV-3 요청 순서대로 0부터 시작하는 연속 정수를 부여한다") + void 요청_순서대로_0부터_연속으로_부여한다() { + Map result = DisplayOrders.reassign(List.of(3L, 1L, 2L), List.of(1L, 2L, 3L)); + + assertThat(result).containsExactly( + Map.entry(3L, 0), + Map.entry(1L, 1), + Map.entry(2L, 2) + ); + } + + @Test + @DisplayName("빈 목록끼리는 예외 없이 빈 결과를 반환한다") + void 빈_목록은_빈_결과를_반환한다() { + assertThat(DisplayOrders.reassign(List.of(), List.of())).isEmpty(); + } + + @Test + @DisplayName("AC-1.5 요청에 존재하지 않는 대상이 누락되면 400 성격의 예외를 던진다") + void 대상이_누락되면_예외를_던진다() { + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 2L), List.of(1L, 2L, 3L))) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("AC-1.5 요청에 존재하지 않는 id가 추가되면 예외를 던진다") + void 존재하지_않는_id가_추가되면_예외를_던진다() { + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 2L, 3L, 4L), List.of(1L, 2L, 3L))) + .isInstanceOf(GlobalException.class); + } + + @Test + @DisplayName("AC-1.5 요청 안에 id가 중복되면 예외를 던진다") + void 요청_안에_id가_중복되면_예외를_던진다() { + // INV-4: reassign은 순수 함수라 예외 시 Map을 전혀 만들지 않는다 — "일부만 적용된 상태"가 + // 존재할 수 없다. 호출자는 반환값을 받은 뒤에만 엔티티를 변경한다. + assertThatThrownBy(() -> DisplayOrders.reassign(List.of(1L, 1L, 2L), List.of(1L, 2L))) + .isInstanceOf(GlobalException.class); + } +}