diff --git a/README.md b/README.md new file mode 100644 index 0000000..5c2af24 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# BCSD Internal API V2 + +BCSD 동아리 인터널(내부 관리) 서비스 백엔드. Spring Boot 4 / Java 21 / PostgreSQL 16. + +## 로컬 실행 + +### 요구 사항 +- JDK 21 +- Docker (로컬 Postgres 컨테이너용) + +### 절차 + +1. `.env` 생성 + ``` + cp .env.example .env + ``` + `.env`를 열어 `JWT_SECRET`을 32바이트 이상 임의 문자열로 채운다. + ``` + openssl rand -base64 32 + ``` + `.env`는 `.gitignore`에 포함되어 있다. + +2. 로컬 Postgres 기동 + ``` + docker compose up -d + ``` + 이미 로컬에 5432 포트를 쓰는 Postgres가 있다면(Postgres.app, `brew services` 등) 충돌한다. + `docker compose ps`로 컨테이너가 healthy인지 확인하고, 충돌 시: + ``` + COMPOSE_POSTGRES_PORT=5433 docker compose up -d + ``` + 와 함께 `.env`에 `DB_PORT=5433`을 추가한다. + +3. 애플리케이션 실행 + ``` + ./gradlew bootRun + ``` + `developmentOnly` 의존성인 `springboot4-dotenv`가 `.env`를 자동으로 읽는다. + 별도 `--spring.profiles.active` 지정은 필요 없다 — `application.yml`의 프로퍼티 기본값이 + 이미 로컬 개발을 기준으로 되어 있다(`DB_HOST:localhost`, `DB_PORT:5432` 등). + +4. 확인 + ``` + curl http://localhost:8080/health + ``` + `OK`(200)가 반환되면 정상이다. 부팅 로그에서 Flyway 마이그레이션이 전부 적용됐는지도 확인한다. + +### 테스트 실행 + +``` +./gradlew test +``` + +Testcontainers가 테스트 실행 시 Postgres 컨테이너를 자동으로 띄우므로 Docker가 실행 중이어야 한다. +로컬 Postgres(2번 단계)와는 별개다 — 테스트는 매번 격리된 컨테이너를 쓴다. + +### 흔히 겪는 문제 + +- **`Cannot find a Java installation on your machine ... {languageVersion=21}`** + 이 프로젝트는 JDK 21 툴체인을 요구한다. JDK 21을 설치한 뒤 `~/.gradle/gradle.properties` + (저장소가 아니라 사용자 홈, 커밋하지 않는다)에 다음을 추가한다. + ``` + org.gradle.java.installations.paths= + ``` + +- **`FATAL: role "postgres" does not exist`로 Flyway 마이그레이션이 실패한다** + 로컬에 이미 떠 있는 다른 Postgres(홈브루 서비스 등)가 5432를 선점하고 있다는 신호다. + 위 2번 단계의 `COMPOSE_POSTGRES_PORT` 대안을 쓴다. + +- **`JWT_SECRET`이 비어 있으면 부팅이 즉시 실패한다.** + `.env`에 32바이트 이상 값을 채웠는지 확인한다(`app.jwt.secret: ${JWT_SECRET}`에 기본값이 없다). + +## 배포 + +`main` push → GitHub Actions → ghcr.io 이미지 빌드 → 서버 SSH 접속 후 기존 컨테이너 종료·재시작(순차 재시작, 수초 다운타임). 서버가 KONECT 운영 서버를 공유하고 API 컨테이너 메모리 한도가 256MiB로 빠듯하므로, 배포 후 `docker stats`와 `free -h`로 확인한다. 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/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3fb8ace --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + postgres: + image: postgres:16-alpine + container_name: bcsd-internal-api-v2-postgres + restart: unless-stopped + ports: + # 로컬에 이미 5432를 쓰는 Postgres가 있다면(Postgres.app, brew services 등) + # COMPOSE_POSTGRES_PORT=5433 docker compose up -d 로 띄우고 .env의 DB_PORT를 맞춘다. + - "${COMPOSE_POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_DB: bcsd_internal_api_v2 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - bcsd-internal-api-v2-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres -d bcsd_internal_api_v2"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + bcsd-internal-api-v2-pgdata: 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/auth/controller/dto/response/LoginResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java index f03d5d6..f63a444 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/auth/controller/dto/response/LoginResponse.java @@ -24,7 +24,7 @@ public static MemberSummary from(Member member) { member.getId(), member.getName(), member.getStudentNumber(), - member.getTrack().name(), + member.getTrack().getCode(), member.getGeneration(), member.getMemberType().name(), member.getUniversity() 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/config/SecurityConfig.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java index c30f7c1..8890f40 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java @@ -40,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/v1/auth/login", "/v1/auth/reissue", "/v1/auth/logout") .permitAll() .requestMatchers("/v1/auth/password/**").permitAll() + .requestMatchers("/v1/tracks/**").permitAll() .requestMatchers("/v1/members/me/initial-setup") .hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL") .requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf( 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/member/controller/dto/response/MemberResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java index 62c060c..b536ec9 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/controller/dto/response/MemberResponse.java @@ -20,7 +20,7 @@ public static MemberResponse from(Member member) { member.getId(), member.getName(), member.getStudentNumber(), - member.getTrack().name(), + member.getTrack().getCode(), member.getGeneration(), member.getMemberType().name(), member.getUniversity(), diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java index 9b36c3a..2b64796 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/model/Member.java @@ -1,13 +1,17 @@ package com.bcsdlab.bcsdinternalapiv2.member.model; import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -38,9 +42,9 @@ public class Member extends BaseTimeEntity { @Column(name = "name", nullable = false, updatable = false) private String name; - @Enumerated(EnumType.STRING) - @Column(name = "track", nullable = false, updatable = false) - private Track track; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_id", nullable = false, updatable = false) + private TrackMaster track; @Column(name = "generation", nullable = false, updatable = false) private String generation; @@ -85,7 +89,7 @@ public class Member extends BaseTimeEntity { private Instant welcomeMailSentAt; @Builder - private Member(String studentNumber, String password, String name, Track track, String generation, + private Member(String studentNumber, String password, String name, TrackMaster track, String generation, MemberType memberType, String university, String email, String phoneNumber, String githubId, MemberStatus status, MemberRole role, Instant passwordChangedAt) { this.studentNumber = studentNumber; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java index 588bff4..c692a23 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/member/service/AdminMemberService.java @@ -10,6 +10,10 @@ import com.bcsdlab.bcsdinternalapiv2.member.controller.dto.response.AdminMemberCreateResponse; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; import com.bcsdlab.bcsdinternalapiv2.global.mail.MailSender; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import java.security.SecureRandom; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -34,6 +38,7 @@ public class AdminMemberService { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); private final MemberRepository memberRepository; + private final TrackMasterRepository trackMasterRepository; private final PasswordEncoder passwordEncoder; private final LoginPageProperties loginPageProperties; private final ApplicationEventPublisher eventPublisher; @@ -53,6 +58,8 @@ public AdminMemberCreateResponse createMember(AdminMemberCreateRequest request) String normalizedPhone = isBlank(request.phoneNumber()) ? null : PhoneNumberNormalizer.normalize(request.phoneNumber()); String normalizedGithubId = GithubIdNormalizer.normalize(request.githubId()); + TrackMaster track = trackMasterRepository.findByCode(request.track().name()) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); String temporaryPassword = generateTemporaryPassword(); Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); @@ -61,7 +68,7 @@ public AdminMemberCreateResponse createMember(AdminMemberCreateRequest request) .studentNumber(request.studentNumber()) .password(passwordEncoder.encode(temporaryPassword)) .name(request.name()) - .track(request.track()) + .track(track) .generation(request.generation()) .memberType(request.memberType()) .university(request.university()) diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java new file mode 100644 index 0000000..437cc6c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackApi.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@Tag(name = "트랙 API (홈페이지 공개)") +public interface TrackApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "공개 트랙 목록", description = "홈페이지 노출 순서(display_order)대로, 공개된 트랙만 반환합니다.") + @GetMapping + List getTracks(); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "트랙 페이지 상세", description = "숨김 처리되었거나 존재하지 않는 트랙은 구분 없이 404를 반환합니다.") + @GetMapping("/{slug}") + TrackDetailResponse getTrack(@PathVariable String slug); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java new file mode 100644 index 0000000..6f4b05e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/TrackController.java @@ -0,0 +1,31 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.service.TrackService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/tracks") +@RequiredArgsConstructor +public class TrackController implements TrackApi { + + private final TrackService trackService; + + @Override + @GetMapping + public List getTracks() { + return trackService.getTracks(); + } + + @Override + @GetMapping("/{slug}") + public TrackDetailResponse getTrack(@PathVariable String slug) { + return trackService.getTrack(slug); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java new file mode 100644 index 0000000..c7990e9 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackDetailResponse.java @@ -0,0 +1,28 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; + +/** + * T-06은 헤더 필드만 채운다. studyPoints(T-08)·techStacks(T-09)·curriculum(T-12)· + * members(T-18)는 각 도메인 티켓이 이 레코드에 필드를 추가하는 방식으로 확장한다 — + * 지금 존재하지 않는 데이터를 위해 빈 자리표시자 구조를 미리 만들지 않는다. + */ +public record TrackDetailResponse( + String slug, + String name, + String tagline, + String heroImageUrl, + String ogImageUrl, + String seoDescription +) { + public static TrackDetailResponse from(TrackPage trackPage) { + return new TrackDetailResponse( + trackPage.getSlug(), + trackPage.getDisplayName(), + trackPage.getTagline(), + trackPage.getHeroImageUrl(), + trackPage.getOgImageUrl(), + trackPage.getSeoDescription() + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java new file mode 100644 index 0000000..4907fd0 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/controller/dto/response/TrackSummaryResponse.java @@ -0,0 +1,12 @@ +package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; + +public record TrackSummaryResponse( + String slug, + String name +) { + public static TrackSummaryResponse from(TrackPage trackPage) { + return new TrackSummaryResponse(trackPage.getSlug(), trackPage.getDisplayName()); + } +} 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/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java new file mode 100644 index 0000000..49ee0bf --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackMaster.java @@ -0,0 +1,41 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 인터널 공용 트랙 마스터. 부원의 소속(member.track_id)이 참조한다. + * + *

홈페이지 노출 프로필({@code track_page}, T-05/T-06)과 의도적으로 분리되어 있다 — 이 클래스는 + * "부원이 어디에 속하는가"만 다룬다. 클래스명을 {@code Track}이 아니라 {@code TrackMaster}로 둔 이유는 + * {@link com.bcsdlab.bcsdinternalapiv2.member.model.Track}(API 요청/응답에 쓰는 코드 enum)과 + * 이름이 겹치면 같은 파일에서 두 타입을 함께 참조해야 하는 곳(예: 요청 DTO → 엔티티 변환)에서 + * import 충돌이 나기 때문이다. + */ +@Getter +@Entity +@Table(name = "track") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackMaster extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, updatable = false) + private String code; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "is_active", nullable = false) + private boolean active; +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java new file mode 100644 index 0000000..92c0eca --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/model/TrackPage.java @@ -0,0 +1,78 @@ +package com.bcsdlab.bcsdinternalapiv2.track.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +/** + * 홈페이지에 노출되는 트랙 프로필. 트랙 마스터({@link TrackMaster}, "부원이 어디에 속하는가")와 + * 의도적으로 분리되어 있다 — 이 엔티티는 "홈페이지에 어떻게 보이는가"만 다룬다(ADR-002/003). + * + *

{@code @SQLRestriction}은 {@link SoftDeletableEntity}(매핑된 슈퍼클래스)가 아니라 + * 이 클래스에 직접 붙어 있다 — Hibernate가 상속시키지 않기 때문이다. + */ +@Getter +@Entity +@Table(name = "track_page") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TrackPage extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "track_id", nullable = false, updatable = false) + private TrackMaster track; + + @Column(name = "slug", nullable = false) + private String slug; + + @Column(name = "display_name", nullable = false) + private String displayName; + + @Column(name = "tagline", nullable = false) + private String tagline; + + @Column(name = "hero_image_url") + private String heroImageUrl; + + @Column(name = "og_image_url") + private String ogImageUrl; + + @Column(name = "seo_description") + private String seoDescription; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private TrackPage(TrackMaster track, String slug, String displayName, String tagline, String heroImageUrl, + String ogImageUrl, String seoDescription, int displayOrder, boolean published) { + this.track = track; + this.slug = slug; + this.displayName = displayName; + this.tagline = tagline; + this.heroImageUrl = heroImageUrl; + this.ogImageUrl = ogImageUrl; + this.seoDescription = seoDescription; + this.displayOrder = displayOrder; + this.published = published; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java new file mode 100644 index 0000000..2f9399b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackMasterRepository.java @@ -0,0 +1,10 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackMasterRepository extends JpaRepository { + + Optional findByCode(String code); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java new file mode 100644 index 0000000..143c057 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/repository/TrackPageRepository.java @@ -0,0 +1,13 @@ +package com.bcsdlab.bcsdinternalapiv2.track.repository; + +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TrackPageRepository extends JpaRepository { + + List findAllByPublishedTrueOrderByDisplayOrderAsc(); + + Optional findBySlugAndPublishedTrue(String slug); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java new file mode 100644 index 0000000..edda357 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/track/service/TrackService.java @@ -0,0 +1,32 @@ +package com.bcsdlab.bcsdinternalapiv2.track.service; + +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException; +import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TrackService { + + private final TrackPageRepository trackPageRepository; + + public List getTracks() { + return trackPageRepository.findAllByPublishedTrueOrderByDisplayOrderAsc().stream() + .map(TrackSummaryResponse::from) + .toList(); + } + + public TrackDetailResponse getTrack(String slug) { + TrackPage trackPage = trackPageRepository.findBySlugAndPublishedTrue(slug) + .orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND)); + return TrackDetailResponse.from(trackPage); + } +} 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/main/resources/db/migration/V7__create_track.sql b/src/main/resources/db/migration/V7__create_track.sql new file mode 100644 index 0000000..fef5a96 --- /dev/null +++ b/src/main/resources/db/migration/V7__create_track.sql @@ -0,0 +1,24 @@ +CREATE TABLE track ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(20) NOT NULL, + name VARCHAR(50) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_track_code UNIQUE (code) +); + +-- member.track(VARCHAR enum)의 10개 값 + 홈페이지에는 있으나 인터널 enum에는 없던 SECURITY. +INSERT INTO track (code, name) VALUES + ('FRONTEND', '프론트엔드'), + ('BACKEND', '백엔드'), + ('ANDROID', '안드로이드'), + ('IOS', 'iOS'), + ('PM', '기획'), + ('DATA', '데이터'), + ('DESIGN', '디자인'), + ('DEVOPS', '데브옵스'), + ('PS', 'PS'), + ('GAME', '게임'), + ('SECURITY', '보안'); diff --git a/src/main/resources/db/migration/V8__add_member_track_id.sql b/src/main/resources/db/migration/V8__add_member_track_id.sql new file mode 100644 index 0000000..1172cd7 --- /dev/null +++ b/src/main/resources/db/migration/V8__add_member_track_id.sql @@ -0,0 +1,14 @@ +-- expand -> migrate -> contract 중 expand+migrate 단계. contract(레거시 컬럼 완전 제거)는 +-- 이번 마이그레이션 범위 밖이다 — 애플리케이션 코드 전환이 배포로 안정화된 뒤 별도 진행한다. + +ALTER TABLE member ADD COLUMN track_id BIGINT; + +UPDATE member m SET track_id = t.id FROM track t WHERE t.code = m.track; + +ALTER TABLE member ALTER COLUMN track_id SET NOT NULL; +ALTER TABLE member ADD CONSTRAINT fk_member_track FOREIGN KEY (track_id) REFERENCES track (id); + +-- 레거시 문자열 컬럼은 남겨 두되(하위 호환 read path), 새 엔티티는 이 컬럼을 매핑하지 않으므로 +-- NOT NULL을 유지하면 새 부원 생성 INSERT가 "track" 컬럼 누락으로 실패한다. CHECK 제약은 +-- NULL을 통과시키므로 ck_member_track과 충돌하지 않는다. +ALTER TABLE member ALTER COLUMN track DROP NOT NULL; diff --git a/src/main/resources/db/migration/V9__create_homepage_content.sql b/src/main/resources/db/migration/V9__create_homepage_content.sql new file mode 100644 index 0000000..a1a1ee9 --- /dev/null +++ b/src/main/resources/db/migration/V9__create_homepage_content.sql @@ -0,0 +1,177 @@ +-- 홈페이지 CMS 스키마 (05-api-spec.md §1.2). track_page는 트랙 마스터(V7)를 참조한다. +-- 최상위 엔티티(track_page/curriculum/activity_category/activity)만 soft delete를 쓰고, +-- 하위 트리는 ON DELETE CASCADE로 물리 삭제한다. + +-- ===== 홈페이지 노출 프로필 (track 1 : track_page 0..1) ===== +CREATE TABLE track_page ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_id BIGINT NOT NULL REFERENCES track (id), + slug VARCHAR(50) NOT NULL, + display_name VARCHAR(50) NOT NULL, + tagline VARCHAR(120) NOT NULL, + hero_image_url VARCHAR(500), + og_image_url VARCHAR(500), + seo_description VARCHAR(200), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_track_page_slug ON track_page (slug) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uq_track_page_track ON track_page (track_id) WHERE deleted_at IS NULL; + +CREATE TABLE track_study_point ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + title VARCHAR(60) NOT NULL, + description VARCHAR(200) NOT NULL, + icon_image_url VARCHAR(500), + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_study_point_page ON track_study_point (track_page_id, display_order); + +CREATE TABLE tech_stack ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(50) NOT NULL, + icon_url VARCHAR(500) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_tech_stack_name UNIQUE (name) +); + +CREATE TABLE track_page_tech_stack ( + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + tech_stack_id BIGINT NOT NULL REFERENCES tech_stack (id) ON DELETE CASCADE, + display_order INT NOT NULL, + + PRIMARY KEY (track_page_id, tech_stack_id) +); + +-- 명부(member)가 이미 구현되어 있으므로 FK를 즉시 적용한다 (ADR-012). +CREATE TABLE track_page_member ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + member_id BIGINT NOT NULL REFERENCES member (id), + display_order INT NOT NULL, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_track_page_member UNIQUE (track_page_id, member_id) +); + +-- ===== 커리큘럼 ===== +CREATE TABLE curriculum ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT FALSE, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +-- INV-2: 트랙당 공개 세트는 1개만 (ADR-004) +CREATE UNIQUE INDEX uq_curriculum_published + ON curriculum (track_page_id) WHERE is_published AND deleted_at IS NULL; + +CREATE TABLE curriculum_week ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + curriculum_id BIGINT NOT NULL REFERENCES curriculum (id) ON DELETE CASCADE, + week_from INT NOT NULL, + week_to INT, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT ck_week_range CHECK (week_to IS NULL OR week_to >= week_from), + CONSTRAINT ck_week_from CHECK (week_from BETWEEN 1 AND 99) +); +CREATE INDEX idx_week_curriculum ON curriculum_week (curriculum_id, display_order); + +CREATE TABLE curriculum_topic ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + week_id BIGINT NOT NULL REFERENCES curriculum_week (id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_topic_week ON curriculum_topic (week_id, display_order); + +CREATE TABLE curriculum_topic_detail ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + topic_id BIGINT NOT NULL REFERENCES curriculum_topic (id) ON DELETE CASCADE, + content VARCHAR(300) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_detail_topic ON curriculum_topic_detail (topic_id, display_order); + +-- ===== 활동 ===== +CREATE TABLE activity_category ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + slug VARCHAR(30) NOT NULL, + name VARCHAR(30) NOT NULL, + headline VARCHAR(200), + hero_image_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); +CREATE UNIQUE INDEX uq_activity_category_slug ON activity_category (slug) WHERE deleted_at IS NULL; + +CREATE TABLE activity ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_id BIGINT NOT NULL REFERENCES activity_category (id), + year INT NOT NULL, + month INT NOT NULL, + title VARCHAR(80) NOT NULL, + summary VARCHAR(200) NOT NULL, + content TEXT, + external_url VARCHAR(500), + display_order INT NOT NULL, + is_published BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + + CONSTRAINT ck_activity_month CHECK (month BETWEEN 1 AND 12), + CONSTRAINT ck_activity_year CHECK (year BETWEEN 2000 AND 2100) +); +CREATE INDEX idx_activity_timeline + ON activity (category_id, year DESC, month DESC, display_order) + WHERE deleted_at IS NULL; + +CREATE TABLE activity_image ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + activity_id BIGINT NOT NULL REFERENCES activity (id) ON DELETE CASCADE, + image_url VARCHAR(500) NOT NULL, + display_order INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_activity_image ON activity_image (activity_id, display_order); + +-- ===== 미디어 라이브러리 (콘텐츠 테이블과 FK로 묶지 않는다 · ADR-009) ===== +CREATE TABLE image_asset ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + s3_key VARCHAR(300) NOT NULL, + url VARCHAR(500) NOT NULL, + original_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100) NOT NULL, + byte_size BIGINT NOT NULL, + purpose VARCHAR(30) NOT NULL, + is_confirmed BOOLEAN NOT NULL DEFAULT FALSE, + uploaded_by BIGINT REFERENCES member (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + CONSTRAINT uk_image_asset_s3_key UNIQUE (s3_key) +); +CREATE INDEX idx_image_asset_library ON image_asset (purpose, created_at DESC) WHERE is_confirmed; 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/auth/InitialSetupIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java index fe17ba8..6898f77 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/InitialSetupIntegrationTest.java @@ -10,8 +10,9 @@ import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.util.ArrayList; @@ -54,9 +55,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -67,13 +73,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); Member member = Member.builder() .studentNumber("20231234") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("홍길동") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java index 9b6fbfb..15a5704 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/LoginIntegrationTest.java @@ -9,8 +9,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -52,9 +53,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -63,13 +69,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); Member member = Member.builder() .studentNumber("20231234") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("홍길동") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -165,7 +172,7 @@ void setUp() { .studentNumber("20236543") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("정하늘") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -213,7 +220,7 @@ void setUp() { .studentNumber("20235432") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("서지훈") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -257,7 +264,7 @@ void setUp() { .studentNumber("20230000") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("잠긴계정") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java index 046200d..f822435 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/PasswordResetIntegrationTest.java @@ -16,8 +16,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import jakarta.servlet.http.Cookie; import java.time.Instant; import java.util.List; @@ -62,9 +63,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -82,6 +88,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); passwordResetTokenRepository.deleteAll(); memberRepository.deleteAll(); @@ -93,7 +100,7 @@ void setUp() { .studentNumber("20236666") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("최수진") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -169,7 +176,7 @@ void setUp() { .studentNumber("20235555") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("오세훈") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java index e28dbde..38e1b2d 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/auth/RefreshTokenIntegrationTest.java @@ -10,8 +10,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.Member; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import jakarta.servlet.http.Cookie; import java.util.ArrayList; import java.util.List; @@ -53,9 +54,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private RefreshTokenRepository refreshTokenRepository; @@ -64,6 +70,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); } @@ -74,7 +81,7 @@ void setUp() { .studentNumber("20237777") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("이민수") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -129,7 +136,7 @@ void setUp() { .studentNumber("20238888") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("박영희") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") @@ -186,7 +193,7 @@ void setUp() { .studentNumber("20239999") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("김철수") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") 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); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java index 4e3e095..81c303e 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberIntegrationTest.java @@ -8,8 +8,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -45,9 +46,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -55,6 +61,7 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); memberRepository.deleteAll(); } @@ -64,7 +71,7 @@ void setUp() { .studentNumber("20234321") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("김민재") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java index 206aa42..fdb9d2e 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/member/AdminMemberWelcomeMailIntegrationTest.java @@ -8,8 +8,9 @@ import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; -import com.bcsdlab.bcsdinternalapiv2.member.model.Track; import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.time.Instant; @@ -49,9 +50,14 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @Autowired private MockMvc mockMvc; + private TrackMaster backend; + @Autowired private MemberRepository memberRepository; + @Autowired + private TrackMasterRepository trackMasterRepository; + @Autowired private PasswordEncoder passwordEncoder; @@ -61,12 +67,13 @@ static void jwtSecret(DynamicPropertyRegistry registry) { @BeforeEach void setUp() throws Exception { + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); memberRepository.deleteAll(); Member admin = Member.builder() .studentNumber("20230001") .password(passwordEncoder.encode(RAW_PASSWORD)) .name("관리자") - .track(Track.BACKEND) + .track(backend) .generation("24-하") .memberType(MemberType.REGULAR) .university("OO대학교") diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java new file mode 100644 index 0000000..2fda6b1 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/HomepageContentSchemaIntegrationTest.java @@ -0,0 +1,181 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * T-05(V9__create_homepage_content.sql)의 제약 조건이 설계대로 동작하는지 검증한다. + * 아직 JPA 엔티티가 없으므로(T-06 이후 도메인 티켓의 몫) raw SQL로 직접 검증한다 — + * partial unique index, CHECK 제약, ON DELETE CASCADE처럼 오타 하나로도 조용히 깨지는 + * DDL을 엔티티가 생기기 전에 먼저 굳혀 둔다. + */ +class HomepageContentSchemaIntegrationTest extends IntegrationTestSupport { + + @Autowired + private JdbcTemplate jdbcTemplate; + + private Long frontendTrackId; + private Long backendTrackId; + + @BeforeEach + void setUp() { + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + jdbcTemplate.update("delete from curriculum"); + jdbcTemplate.update("delete from track_page"); + jdbcTemplate.update("delete from image_asset"); + + frontendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'FRONTEND'", Long.class); + backendTrackId = jdbcTemplate.queryForObject( + "select id from track where code = 'BACKEND'", Long.class); + } + + @Test + @DisplayName("INV-1 slug는 삭제되지 않은 track_page끼리만 유일하다") + void track_page_slug는_삭제되지_않은_행끼리만_유일하다() { + insertTrackPage(frontendTrackId, "frontend"); + + assertThatThrownBy(() -> insertTrackPage(backendTrackId, "frontend")) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update("update track_page set deleted_at = now() where slug = 'frontend'"); + + // 예외를 던지지 않으면 성공 + insertTrackPage(backendTrackId, "frontend"); + } + + @Test + @DisplayName("INV-2 한 트랙에 공개 커리큘럼 세트는 하나만 허용한다") + void 공개_커리큘럼은_트랙당_하나만_허용한다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> insertCurriculum(trackPageId, "심화", true)) + .isInstanceOf(DataIntegrityViolationException.class); + + // 공개가 아니면 여러 개 허용 + insertCurriculum(trackPageId, "초안", false); + } + + @Test + @DisplayName("INV-5 week_to가 week_from보다 작으면 거부된다") + void 주차_범위가_거꾸로면_거부된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + + assertThatThrownBy(() -> jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 5, 3, 0)", curriculumId)) + .isInstanceOf(DataIntegrityViolationException.class); + + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 13, 16, 0)", curriculumId); + jdbcTemplate.update( + "insert into curriculum_week (curriculum_id, week_from, week_to, display_order) " + + "values (?, 4, null, 1)", curriculumId); + } + + @Test + @DisplayName("INV-6 활동의 month/year 범위를 벗어나면 거부된다") + void 활동_연월_범위를_벗어나면_거부된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + + assertThatThrownBy(() -> insertActivity(categoryId, 2019, 13)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThatThrownBy(() -> insertActivity(categoryId, 1999, 5)) + .isInstanceOf(DataIntegrityViolationException.class); + + insertActivity(categoryId, 2019, 5); + } + + @Test + @DisplayName("INV-7 커리큘럼을 삭제하면 주차·토픽·세부항목이 cascade로 함께 삭제된다") + void 커리큘럼_삭제는_하위_트리에_cascade된다() { + Long trackPageId = insertTrackPage(frontendTrackId, "frontend"); + Long curriculumId = insertCurriculum(trackPageId, "비기너", true); + Long weekId = jdbcTemplate.queryForObject( + "insert into curriculum_week (curriculum_id, week_from, display_order) " + + "values (?, 1, 0) returning id", + Long.class, curriculumId); + Long topicId = jdbcTemplate.queryForObject( + "insert into curriculum_topic (week_id, title, display_order) values (?, '웹 동작 방식', 0) " + + "returning id", + Long.class, weekId); + jdbcTemplate.update( + "insert into curriculum_topic_detail (topic_id, content, display_order) values (?, 'HTTP', 0)", + topicId); + + jdbcTemplate.update("delete from curriculum where id = ?", curriculumId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_week where id = ?", Integer.class, weekId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic where id = ?", Integer.class, topicId)).isZero(); + assertThat(jdbcTemplate.queryForObject( + "select count(*) from curriculum_topic_detail where topic_id = ?", Integer.class, topicId)) + .isZero(); + } + + @Test + @DisplayName("INV-7 활동을 삭제하면 사진도 cascade로 함께 삭제된다") + void 활동_삭제는_사진에_cascade된다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + Long activityId = insertActivity(categoryId, 2019, 5); + jdbcTemplate.update( + "insert into activity_image (activity_id, image_url, display_order) values (?, 'https://x/1.png', 0)", + activityId); + + jdbcTemplate.update("delete from activity where id = ?", activityId); + + assertThat(jdbcTemplate.queryForObject( + "select count(*) from activity_image where activity_id = ?", Integer.class, activityId)) + .isZero(); + } + + @Test + @DisplayName("카테고리에 활동이 남아 있으면 FK 제약으로 삭제가 거부된다 (AC-3.8의 DB 쪽 근거)") + void 활동이_남은_카테고리는_삭제할_수_없다() { + Long categoryId = insertActivityCategory("event", "EVENT"); + insertActivity(categoryId, 2019, 5); + + assertThatThrownBy(() -> jdbcTemplate.update("delete from activity_category where id = ?", categoryId)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Long insertTrackPage(Long trackId, String slug) { + return jdbcTemplate.queryForObject( + "insert into track_page (track_id, slug, display_name, tagline, display_order) " + + "values (?, ?, ?, 'tagline', 0) returning id", + Long.class, trackId, slug, slug); + } + + private Long insertCurriculum(Long trackPageId, String name, boolean published) { + return jdbcTemplate.queryForObject( + "insert into curriculum (track_page_id, name, is_published, display_order) " + + "values (?, ?, ?, 0) returning id", + Long.class, trackPageId, name, published); + } + + private Long insertActivityCategory(String slug, String name) { + return jdbcTemplate.queryForObject( + "insert into activity_category (slug, name, display_order) values (?, ?, 0) returning id", + Long.class, slug, name); + } + + private Long insertActivity(Long categoryId, int year, int month) { + return jdbcTemplate.queryForObject( + "insert into activity (category_id, year, month, title, summary, display_order) " + + "values (?, ?, ?, 't', 's', 0) returning id", + Long.class, categoryId, year, month); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java new file mode 100644 index 0000000..7289fa9 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackIntegrationTest.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TrackIntegrationTest extends IntegrationTestSupport { + + @Autowired + private TrackPageRepository trackPageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + private TrackMaster frontend; + private TrackMaster backend; + private TrackMaster pm; + + @BeforeEach + void setUp() { + trackPageRepository.deleteAll(); + frontend = trackMasterRepository.findByCode("FRONTEND").orElseThrow(); + backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + pm = trackMasterRepository.findByCode("PM").orElseThrow(); + } + + @Test + @DisplayName("AC-1.3 공개 목록은 공개된 트랙만 display_order 순으로 반환한다") + void 공개_목록은_display_order_순이고_숨김_트랙은_제외한다() throws Exception { + // uq_track_page_track은 track_id 단위 partial unique index라, 활성 상태(미삭제)인 + // track_page는 트랙마다 하나뿐이어야 한다 — 숨김 케이스는 별도 트랙 마스터(pm)를 쓴다. + trackPageRepository.save(trackPage(backend, "backend", 1, true)); + trackPageRepository.save(trackPage(frontend, "frontend", 0, true)); + trackPageRepository.save(trackPage(pm, "hidden", 2, false)); + + mockMvc.perform(get("/v1/tracks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)) + .andExpect(jsonPath("$[0].slug").value("frontend")) + .andExpect(jsonPath("$[1].slug").value("backend")); + } + + @Test + @DisplayName("공개 트랙 상세는 인증 없이 200과 헤더 필드를 반환한다") + void 공개_트랙_상세는_인증_없이_조회된다() throws Exception { + trackPageRepository.save(TrackPage.builder() + .track(frontend) + .slug("frontend") + .displayName("Frontend") + .tagline("사용자 인터페이스를 구축합니다.") + .heroImageUrl("https://image.bcsdlab.com/hero.png") + .displayOrder(0) + .published(true) + .build()); + + mockMvc.perform(get("/v1/tracks/frontend")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.slug").value("frontend")) + .andExpect(jsonPath("$.name").value("Frontend")) + .andExpect(jsonPath("$.tagline").value("사용자 인터페이스를 구축합니다.")) + .andExpect(jsonPath("$.heroImageUrl").value("https://image.bcsdlab.com/hero.png")); + } + + @Test + @DisplayName("INV-11 숨김 트랙은 slug를 알아도 404다 (존재하지 않는 slug와 구분되지 않는다)") + void 숨김_트랙은_404다() throws Exception { + trackPageRepository.save(trackPage(frontend, "hidden-track", 0, false)); + + mockMvc.perform(get("/v1/tracks/hidden-track")).andExpect(status().isNotFound()); + mockMvc.perform(get("/v1/tracks/no-such-slug")).andExpect(status().isNotFound()); + } + + private TrackPage trackPage(TrackMaster track, String slug, int order, boolean published) { + return TrackPage.builder() + .track(track) + .slug(slug) + .displayName(slug) + .tagline("tagline") + .displayOrder(order) + .published(published) + .build(); + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java new file mode 100644 index 0000000..64b966d --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/track/TrackPromotionIntegrationTest.java @@ -0,0 +1,114 @@ +package com.bcsdlab.bcsdinternalapiv2.track; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * T-35(member.track enum → 참조 테이블 승격)의 V7/V8 마이그레이션 결과를 검증한다. + * {@link IntegrationTestSupport}의 두 번째 소비자다. + */ +class TrackPromotionIntegrationTest extends IntegrationTestSupport { + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() { + memberRepository.deleteAll(); + } + + @Test + @DisplayName("AC-8.1 track 테이블에 기존 10개 + SECURITY 총 11행이 시드된다") + void 트랙_11개가_시드된다() { + assertThat(trackMasterRepository.count()).isEqualTo(11); + assertThat(trackMasterRepository.findByCode("SECURITY")).isPresent(); + } + + @Test + @DisplayName("AC-8.2 새로 생성한 부원의 track_id는 null이 아니다") + void 신규_부원의_track_id는_null이_아니다() { + var backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + Member member = memberRepository.save(newMember(backend)); + + Integer nullCount = jdbcTemplate.queryForObject( + "select count(*) from member where id = ? and track_id is null", + Integer.class, member.getId()); + + assertThat(nullCount).isZero(); + } + + @Test + @DisplayName("AC-8.3 V8 백필 결과가 원본 문자열 값과 정확히 일치한다") + void 백필된_track_id가_레거시_문자열과_일치한다() { + // 기존 통합 테스트들이 이미 여러 부원을 만들어 지나갔을 수 있으므로, 여기서 직접 하나 만들어 + // "이 마이그레이션이 실제로 무엇을 했는지"를 이 테스트 스스로 검증한다. + var backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + memberRepository.save(newMember(backend)); + + Integer mismatchCount = jdbcTemplate.queryForObject( + "select count(*) from member m join track t on t.id = m.track_id where t.code <> m.track", + Integer.class); + + assertThat(mismatchCount).isZero(); + } + + @Test + @DisplayName("AC-8.4 레거시 track 컬럼은 NOT NULL이 풀려 있다 (신버전 INSERT가 그 컬럼을 채우지 않아도 된다)") + void 레거시_track_컬럼은_not_null이_아니다() { + Integer nullableCount = jdbcTemplate.queryForObject( + "select count(*) from information_schema.columns " + + "where table_name = 'member' and column_name = 'track' and is_nullable = 'YES'", + Integer.class); + + assertThat(nullableCount).isEqualTo(1); + } + + @Test + @DisplayName("AC-8.4 caveat: track_id 없이 넣는 구버전 스타일 INSERT는 이제 실패한다 " + + "(쓰기 경로는 신버전 배포 후에만 안전하다 — 읽기 전용 하위호환)") + void track_id_없는_구버전_스타일_insert는_실패한다() { + assertThatThrownBy(() -> jdbcTemplate.update( + "insert into member (student_number, password, name, track, generation, member_type, " + + "university, email, status, role) " + + "values ('99999999', 'x', '레거시부원', 'BACKEND', '24-하', 'REGULAR', " + + "'OO대학교', 'legacy-style@bcsd.club', 'PENDING_SETUP', 'MEMBER')")) + .isInstanceOf(DataIntegrityViolationException.class); + } + + private Member newMember(com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster track) { + return Member.builder() + .studentNumber("2023" + (int) (Math.random() * 9000 + 1000)) + .password(passwordEncoder.encode("Temp1234")) + .name("트랙승격테스트") + .track(track) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("track-promotion-" + System.nanoTime() + "@bcsd.club") + .status(MemberStatus.ACTIVE) + .build(); + } +}